Travel CA Exception

Gestión de excepciones de Acceso Condicional para empleados de viaje
Cuando un usuario necesita trabajar desde un país bloqueado por política de seguridad, este sistema crea temporalmente una Named Location y una CA policy individuales que permiten el acceso exclusivamente desde ese país, durante el periodo del viaje. Dispone de dos modos de operación: con aprobación (auth delegada MSAL + claims challenge, operado por el equipo de Sistemas) y autoservicio (daemon token con certificado, el helpdesk activa sin intervención adicional).

PowerShell Microsoft Graph API Entra ID CA Python Flask En producción

Contenido
  1. Arquitectura de seguridad del tenant
  2. Script local — Manage-TravelCAException.ps1
  3. Lógica Enable (activar viaje)
  4. Lógica Disable (desactivar al volver)
  5. Soporte multi-país
  6. Problemas en el script PowerShell
  7. Azure Automation — Invoke-TravelCAException.ps1
  8. Protected Actions CA — investigación y solución
  9. Travel CA Approver — web app (solución definitiva)
  10. Modos de operación: con aprobación vs. autoservicio
  11. Problemas durante el despliegue
  12. Integración Jira — resumen completo
  13. Estado del proyecto
  14. Configuración de referencia

1. Arquitectura de seguridad del tenant

Política CA principal — Risky Countries

CampoValor
NombreRisky Countries - 01 - Default
ID9d0adb2b-ca25-4278-80ec-c627b3611bf8
LógicaBloquea acceso si el usuario viene de #04 - Risky Countries, a menos que esté en las exclusiones
includeLocations#04 - Risky Countries — ID 1f753369-b550-4df1-9811-60709007b8c6
excludeLocations#00 - Permanent Allowed Countries (siempre permitidos) + #01 - Temporal Risky Countries Exceptions
excludeGroupsGrupos existentes + CA - Travel Exceptions (nuevo, para los viajeros)

Política CA #0002 — Protected Actions

CampoValor
Nombre#0002 - Global - Protected Actions CA
Efecto originalAsignaba el auth context c1 a las operaciones de escritura CA, exigiendo acrs=c1 en el token.
Estado actualLas operaciones namedLocations/create|delete y conditionalAccessPolicies/create|delete han sido eliminadas del auth context, permitiendo que un daemon token (client credentials) las ejecute directamente.
ImpactoEl modo con aprobación (delegado) sigue funcionando igual. El modo autoservicio (daemon/SisScripts) puede operar sin MFA step-up.

Elementos creados para este proyecto

ElementoNombreIDPropósito
Security Group CA - Travel Exceptions 9ef82868-2d3f-4abd-96eb-cfbc3783addc Los usuarios en este grupo quedan excluidos de la CA principal. Ya añadido como excludeGroup.
Named Location (por viaje) Travel | DisplayName | País | hasta YYYY-MM-DD Generado dinámicamente Contiene el/los países de destino. Se usa como excludeLocations en la CA policy individual.
CA Policy (por viaje) Travel | UPN | País | hasta YYYY-MM-DD Generado dinámicamente Bloquea al usuario en todos los países de riesgo excepto el de destino.

Lógica de la excepción (por qué funciona así)

La CA principal bloquea a cualquier usuario que venga de #04 - Risky Countries excepto si está en excludeGroups. Al añadir al viajero a CA - Travel Exceptions, queda excluido de esa política.

Sin embargo, eso solo solucionaría el problema para ese usuario sin distinción de país. Para ser más granulares, creamos además una CA policy individual que dice: "bloquea a este usuario si viene de #04 - Risky Countries, excepto si viene del país donde está de viaje". Así el viajero puede trabajar desde Francia, pero no desde cualquier otro país de riesgo.

El resultado es que el viajero queda cubierto por dos mecanismos simultáneos: exclusión del grupo (exime de la política global) y CA policy individual (restringe al país concreto).


2. Script local — Manage-TravelCAException.ps1

Ruta: scripts_sistemas\windows\AzureAD\CA\Manage-TravelCAException.ps1

Prerrequisito

Install-Module Microsoft.Graph -Scope CurrentUser

Uso

# Activar excepción para un viaje
.\Manage-TravelCAException.ps1 -Action Enable -UPN [email protected] -CountryCode FR -EndDate 2026-08-25

# Activar para varios países
.\Manage-TravelCAException.ps1 -Action Enable -UPN [email protected] -CountryCode FR,IT,DE -EndDate 2026-08-25

# Ver viajes activos
.\Manage-TravelCAException.ps1 -Action List

# Desactivar al volver
.\Manage-TravelCAException.ps1 -Action Disable -UPN [email protected]

Parámetros

ParámetroObligatorioDescripción
-ActionEnable, Disable o List
-UPNUPN del usuario viajero (p. ej. [email protected])
-CountryCodeSolo EnableCódigos ISO 3166-1 alpha-2 separados por coma. Ej: FR o FR,IT,DE
-EndDateSolo EnableFecha de fin del viaje en formato YYYY-MM-DD. Debe ser futura.

Autenticación

Delegada interactiva: Connect-MgGraph abre el navegador para login. Scopes requeridos: Policy.ReadWrite.ConditionalAccess, Policy.Read.All, Group.ReadWrite.All, User.Read.All.

Audit trail

Cada Enable escribe una línea en TravelExceptions_Audit.csv (en la misma carpeta del script). El Disable lee ese fichero para obtener el PolicyId y LocationId, y limpia la entrada al finalizar. Limitación: el CSV es local al equipo desde el que se ejecuta el script.


3. Lógica Enable (activar viaje)

  1. Validar CountryCode (formato ISO 3166-1 alpha-2, uno o varios) y EndDate (fecha futura).
  2. Comprobar que no hay excepción activa previa para ese UPN en el CSV.
  3. GET /users/{UPN} — obtener Id y DisplayName.
  4. POST /identity/conditionalAccess/namedLocations — crear countryNamedLocation con los países de destino.
  5. Bucle de espera de propagación: GET cada 5 s hasta que la Named Location sea visible, máximo 60 s.
  6. Sleep adicional de 15 s para que el motor CA interno replique el objeto.
  7. POST /identity/conditionalAccess/policies — crear CA policy individual (con reintentos por error 1040).
  8. POST /groups/{TravelGroupId}/members/$ref — añadir usuario al grupo CA - Travel Exceptions.
  9. Guardar en CSV: UPN, DisplayName, CountryCode, EndDate, PolicyId, LocationId, CreatedAt.

4. Lógica Disable (desactivar al volver)

  1. Leer CSV, buscar entrada por UPN → obtener PolicyId y LocationId.
  2. DELETE /identity/conditionalAccess/policies/{PolicyId}.
  3. DELETE /identity/conditionalAccess/namedLocations/{LocationId} — con reintentos por error 1178.
  4. DELETE /groups/{TravelGroupId}/members/{userId}/$ref.
  5. Limpiar entrada del CSV.
Cada paso tiene try/catch independiente para que un error parcial no impida limpiar el resto.

5. Soporte multi-país

El parámetro -CountryCode acepta varios códigos separados por coma (FR,IT,DE). El script crea una sola Named Location con todos los países y una sola CA policy, en lugar de una policy por país.

Ventajas de este enfoque:

Los códigos de país se almacenan en el CSV separados por | para no romper el formato CSV. En la base de datos SQLite de la web app se almacenan separados por coma.


6. Problemas encontrados en el script PowerShell

Error 1040 — Named Location no existe en el motor CA

Síntoma: POST /identity/conditionalAccess/policies devuelve 400 Bad Request con código 1040: "NamedLocation does not exist in the directory".
Causa: Graph API tiene consistencia eventual. La Named Location existe en la capa de lectura pero el motor interno de CA aún no la ha propagado.
Solución aplicada:
  1. Bucle GET tras el POST de la Named Location (cada 5 s, máx 60 s) para confirmar visibilidad en API.
  2. Sleep adicional de 15 s para dar tiempo al motor CA interno.
  3. Reintentos en el POST de la policy (3×10 s) si sigue devolviendo 1040.
Esta misma lógica de reintentos se trasladó a la web app Python.

Error "already exist" al añadir al grupo

Síntoma: POST /groups/.../members/$ref falla indicando que el usuario ya es miembro.
Causa: Si se ejecuta Enable dos veces para el mismo usuario (p. ej. tras un fallo parcial), el grupo ya tiene al usuario.
Solución aplicada: try/catch que comprueba si el error contiene "already exist" y lo ignora silenciosamente.

Error 1178 — Named Location referenciada al borrar

Síntoma: DELETE /identity/conditionalAccess/namedLocations/{id} devuelve 400 con código 1178: "This object cannot be deleted because it is referenced by one or more conditional access policies".
Causa: La CA policy fue eliminada momentos antes, pero el motor CA aún no propagó esa eliminación cuando se intenta borrar la Named Location.
Solución aplicada: Bucle de reintentos en el DELETE de Named Location (4 intentos × 10 s).

Parámetro [switch] falla en Azure Automation

Síntoma: "A positional parameter cannot be found that accepts argument 'True'".
Causa: Azure Automation pasa los parámetros de tipo [switch] como la cadena "True", no como valor booleano.
Solución aplicada: Cambiar [switch]$UseManagedIdentity por [bool]$UseManagedIdentity = $false.

7. Azure Automation — Invoke-TravelCAException.ps1

Ruta: scripts_sistemas\windows\AzureAD\CA\Invoke-TravelCAException.ps1

Script separado (sin CSV, sin prompts interactivos, sin colores) diseñado para ejecutarse como runbook en Azure Automation.

Arquitectura planificada

ElementoDetalle
Automation AccountSISScripts — resource group rg-sistemas
IdentidadUAMI uami-sisscripts — Client ID e7555954-c0d0-4d8d-8fa5-fdb5fcccbe9b
Auth en runbookConnect-MgGraph -Identity -ClientId $ManagedIdentityClientId -NoWelcome
EjecuciónAzure sandbox (no Hybrid Worker — ver problemas abajo)
Disable sin IDsBusca la CA policy activa por nombre (Travel | $UPN |*) y extrae el LocationId de conditions.locations.excludeLocations[0]

Permisos asignados a la UAMI

PermisoTipoPara qué
User.Read.AllApplicationResolver UPN → Id y DisplayName
Policy.Read.AllApplicationLeer CA policies existentes (Disable)
Policy.ReadWrite.ConditionalAccessApplicationCrear/eliminar Named Locations y CA policies
Group.ReadWrite.AllApplicationAñadir/quitar usuario del grupo Travel Exceptions

Problemas encontrados durante la implementación

Hybrid Worker Arc — UAMI con -ClientId no soportada

Síntoma: Connect-MgGraph -Identity -ClientId fallaba en el Hybrid Worker Extension-based (Arc): "User assigned identity is currently not supported — clientID must not be passed in request".
Causa: El endpoint IMDS de Azure Arc no soporta UAMI con ClientId explícito. La asignación de UAMI a recursos Arc está en preview y no disponible en este tenant.
Conclusión: El runbook es completamente funcional ejecutado en Azure sandbox. No puede ejecutarse desde el Hybrid Worker on-prem. Para la web app on-prem se usa la App Registration SisScripts con autenticación por certificado (ver sección 10).

8. Protected Actions CA — investigación y solución

Error observado inicialmente: 403 — POST namedLocations: required scopes are missing in the token
Operaciones afectadas: namedLocations/create|delete, conditionalAccessPolicies/create|delete
Causa raíz inicial: la política #0002 - Global - Protected Actions CA asignaba el auth context c1 a estas operaciones.

Por qué las identidades de servicio no pueden satisfacer Protected Actions

Para usuarios interactivos: el browser redirige a Entra ID → MFA step-up → token con acrs=c1 → Graph acepta.
Para una Managed Identity o Service Principal (client credentials): no hay usuario interactivo → no existe mecanismo para satisfacer el challenge → siempre 403.

Caminos explorados y descartados

OpciónResultado
Desactivar #0002 globalmenteFunciona, pero elimina la protección MFA para todos los admins de CA — no válido
CA policy de Workload Identity con "Grant access"No viable — Microsoft solo expone "Block access" en Grant para Workload Identity policies
Añadir UAMI a exclusión de #0002No viable — la UAMI (tipo ManagedIdentity SP) no aparece en el picker de Enterprise Apps
Microsoft Entra Workload ID PremiumConfirmado que NO resuelve el problema. La licencia trial fue activada. Su propósito es CA policies para workload identities, no afecta a Protected Actions para MIs.

Solución definitiva adoptada

Eliminación de las operaciones CA del auth context #0002: Las cuatro operaciones (namedLocations/create, namedLocations/delete, conditionalAccessPolicies/create, conditionalAccessPolicies/delete) fueron eliminadas del auth context en la política #0002. Esto permite que la App Registration SisScripts (client credentials) ejecute las operaciones directamente.

El modo con aprobación (delegado) mantiene el claims challenge flow con MFA step-up tal como estaba. La reducción de Protected Actions es un tradeoff consciente aceptado por el equipo de seguridad.

Segundo bloqueador: Policy.Read.All faltante

Síntoma: Tras eliminar las operaciones de Protected Actions, la App Registration SisScripts (250c7cb4) seguía devolviendo 403 al crear Named Locations.
Causa: Aunque tenía Policy.ReadWrite.ConditionalAccess, le faltaba Policy.Read.All como permiso Application. La UAMI e7555954 sí lo tenía, de ahí que el runbook funcionara y la app no.
Solución: Añadir Policy.Read.All como permiso Application a 250c7cb4 y hacer Admin Consent. El 403 desapareció inmediatamente.

Detalle técnico: claims challenge flow (modo con aprobación)

El modo con aprobación sigue usando auth delegada con claims challenge. Graph no incluye el claim acrs en los tokens por defecto. Para que el cliente reciba las instrucciones del step-up, debe declarar que soporta claims challenges mediante client_capabilities=["CP1"] en MSAL.

Con CP1 activo, cuando Graph rechaza la operación, devuelve 401 con el challenge embebido en el cuerpo JSON. La app extrae el valor base64, lo decodifica, y lo pasa como parámetro claims en la siguiente llamada a get_authorization_request_url(). Entra ID interpreta el parámetro claims y exige el MFA step-up antes de emitir el nuevo token con acrs=c1.

Este mecanismo ya no es necesario para las operaciones CA (eliminadas del auth context), pero la infraestructura de claims challenge se mantiene en el código por si en el futuro se añaden otras operaciones protegidas.


9. Travel CA Approver — web app (solución definitiva)

Ruta: jmfernandez\travel-ca-approver\ — Puerto 8091 — WinSW en servidor sisapps

En producción. Enable y Disable verificados end-to-end en ambos modos. Integración Jira completa. Scheduler diario operativo.

Arquitectura técnica

ElementoDetalle
StackPython 3 + Flask + Waitress (WSGI), SQLite WAL, APScheduler
ServicioWinSW (TravelCAApprover) en servidor sisapps
Puerto8091
App Proxyhttps://travelcaapprover-idealistaa82505660.msappproxy.net
App Proxy pre-authMicrosoft Entra ID + SSO type Header-based (X-MS-CLIENT-PRINCIPAL-NAME)
Auth delegada (modo con aprobación)MSAL auth code flow + client_capabilities=["CP1"] — App Reg 6a0be082
Auth daemon (modo autoservicio)MSAL client credentials con certificado — App Reg SisScripts 250c7cb4
RolesRequester (helpdesk) y Approver (sysadmin) — Enterprise App 6a0be082
Tokens de acciónUUID de un solo uso en SQLite con expires_at
Toggle de modoSetting approval_required en tabla SQLite settings — Approver lo cambia desde el dashboard
JiraIntegración condicional al modo activo (ver sección 12)
SchedulerAPScheduler diario 08:00 Europe/Madrid + trigger manual desde dashboard
Deploydeploy.ps1 — git pull + pip install + restart service

Roles en Enterprise App

RolAsignado aAcceso
RequesterEquipo helpdesk/request — crear solicitud
ApproverEquipo sysadminDashboard completo, toggle modo, scheduler manual, aprobar/deshabilitar

Autocompletado de usuarios

El campo UPN en /request consulta /api/users. El backend carga los miembros de los grupos configurados en users_group_ids (dos grupos: usuarios internos + viajeros frecuentes) y los cachea en memoria durante 5 minutos. La búsqueda es solo sobre esa caché local — no hay fallback a Graph en tiempo real. Al seleccionar un usuario, el campo pierde el foco automáticamente.

Selector multi-país

250 códigos ISO 3166-1 alpha-2 cargados en el frontend. Selección múltiple con búsqueda por nombre o código. Al seleccionar un país, el dropdown se cierra y el foco sale del campo.


10. Modos de operación: con aprobación vs. autoservicio

El modo activo se almacena en la tabla settings de SQLite bajo la clave approval_required ("true" / "false"). Un usuario con rol Approver puede cambiarlo desde el dashboard con el botón de toggle (🔒 / ⚡).

Modo con aprobación (approval_required = true)

EventoQué ocurre
Nueva solicitudCrea registro pending en DB + token UUID + incidencia SIS en Jira enlazada a la incidencia SOS
ActivaciónAdmin recibe ticket SIS, abre enlace de aprobación, se autentica con MFA (claims challenge), la app ejecuta las 4 operaciones Graph con token delegado. Comenta en SOS y en SIS.
Desactivación manualGenera token de disable, redirige a la página de aprobación con el mismo claims challenge flow. Comenta en SOS y en SIS.
SchedulerPor cada excepción vencida: genera token de disable. Abre un único ticket SIS en Jira con tabla de vencidas y enlaces de desactivación. El sysadmin actúa manualmente.

Modo autoservicio (approval_required = false)

EventoQué ocurre
Nueva solicitudActiva directamente vía daemon token (SisScripts 250c7cb4 + certificado). Sin incidencia SIS, sin token de aprobación. Muestra spinner de progreso mientras operan las llamadas Graph.
ActivaciónInmediata, sin intervención del Approver. Comenta solo en la incidencia SOS. El campo jira_key (SIS) queda vacío.
Desactivación manualEl Approver pulsa "Deshabilitar" en el dashboard → spinner → daemon token ejecuta las 3 operaciones Graph de disable. Comenta solo en SOS.
SchedulerPor cada excepción vencida: llama directamente a graph.disable() con daemon token, marca como disabled en DB, comenta en SOS. Sin ticket SIS.

Auth daemon — App Registration SisScripts

El token daemon se adquiere con MSAL acquire_token_for_client (client credentials). La clave privada se lee en tiempo de ejecución del almacén de certificados de Windows (CurrentUser\My) vía PowerShell subprocess, devolviendo el PEM para que MSAL lo use como client_credential.

El resultado se cachea en memoria en un objeto ConfidentialClientApplication global que solo se reconstruye si cambia la configuración. MSAL gestiona la renovación del token internamente.

La sección daemon_graph en app-config.json configura esta identidad separada de la auth delegada. Si la sección no existe, hace fallback a la sección auth.

Flujo completo — Nueva solicitud en modo con aprobación

  1. Helpdesk abre /request. Rellena incidencia SOS, UPN (autocompletado desde grupos configurados), países de destino (selector multi-país), y fecha de fin del viaje.
  2. La app valida y crea el registro en SQLite con status: pending. Genera token UUID de aprobación (24 h de expiración).
  3. Jira: crea tarea SIS con enlace de aprobación. Enlaza la tarea SIS a la incidencia SOS y añade etiqueta Travel-CA-Exception al ticket SOS.
  4. La página de confirmación muestra los links a ambos tickets.

Flujo completo — Aprobación en modo con aprobación

  1. Admin abre /approve?token=UUID desde el ticket SIS.
  2. La app muestra detalle de la solicitud. Al pulsar "Ejecutar activación", MSAL inicia auth code flow con CP1.
  3. Admin se autentica. Se obtiene el primer token (sin acrs).
  4. La app intenta POST namedLocations. Si Graph devuelve claims challenge, parsea el challenge y redirige al admin para MFA step-up.
  5. Con el nuevo token + acrs=c1 (o directamente si no hay Protected Actions en estas operaciones): ejecuta las 4 operaciones Graph.
  6. Excepción marcada active en SQLite. Jira: comentario en SOS y en SIS.

Flujo completo — Desactivación en modo con aprobación

  1. Admin pulsa "Deshabilitar" en el dashboard → redirige a /approve?token=UUID (token de disable).
  2. Mismo claims challenge flow → token con step-up si es necesario.
  3. 3 operaciones Graph: DELETE policy → DELETE namedLocation (con reintentos 1178) → DELETE grupo.
  4. Excepción marcada disabled. Jira: comentario en SOS y en SIS.

Flujo completo — Cancelación (ambos modos)

  1. Solo aplica a excepciones pending (no activadas aún).
  2. Admin pulsa "Cancelar" en el dashboard. Sin llamadas Graph.
  3. Excepción marcada cancelled en SQLite. Jira: comentario en SOS.

Scheduler — Comportamiento según modo

Cuándo corre: APScheduler ejecuta diariamente a las 08:00 Europe/Madrid. También puede lanzarse manualmente desde el dashboard (botón "Revisar vencidas").

Consulta: excepciones con status=active y end_date < today.

Si approval_required = true:

  1. Por cada excepción vencida: genera token UUID de disable (14 días expiración) → almacena en SQLite.
  2. Crea un único ticket SIS en Jira con tabla wiki de vencidas (UPN | Países | Fecha | Enlace de desactivación).
  3. Enlaza ese ticket de expiración a cada ticket SIS de activación individual.
  4. El sysadmin abre cada enlace de desactivación y aprueba manualmente.

Si approval_required = false (autoservicio):

  1. Adquiere daemon token (SisScripts 250c7cb4 + certificado).
  2. Por cada excepción vencida: llama a graph.disable() directamente, marca como disabled en SQLite, añade comentario a la incidencia SOS.
  3. No crea tokens de disable, no abre tickets SIS.

11. Problemas encontrados durante el despliegue

1 — "No autorizado" tras deploy inicial

Causa 1: App Proxy tenía pre-autenticación en "Passthrough" — sin inyección de X-MS-CLIENT-PRINCIPAL-NAME.
Causa 2: SSO type no era "Header-based".
Solución: En Entra ID → Enterprise Applications → Travel CA Approver → Application Proxy: Pre-authentication = "Microsoft Entra ID", SSO type = "Header-based".

2 — Admin entraba como Requester (rol vacío)

Causa: GET /users/{upn}/appRoleAssignments pagina de 50 en 50. El rol Approver estaba en la segunda página (usuario con más de 50 app role assignments en el tenant).
Solución: Cambiar a GET /servicePrincipals/{sp_id}/appRoleAssignedTo. Este endpoint devuelve solo los assignments de nuestra app, sin paginación problemática. Se filtra en Python por principalId.

3 — HTTP 403 en GET /users/{upn}

Causa: Faltaba User.Read.All como permiso Application (solo existía el delegado).
Solución: Añadir User.Read.All Application + Admin Consent.

4 — Autocompletado devolvía array vacío

Causa: GET /groups/{id}/members requería GroupMember.Read.All Application. Solo estaba Group.ReadWrite.All delegado.
Solución: Añadir GroupMember.Read.All Application + Admin Consent.

5 — HTTP 403 en POST namedLocations con SisScripts

Síntoma: La App Registration 250c7cb4 tenía Policy.ReadWrite.ConditionalAccess con admin consent y el token lo confirmaba, pero Graph devolvía 403 con "required scopes are missing".
Causa: Faltaba Policy.Read.All como permiso Application. La UAMI e7555954 sí lo tenía (de ahí que el runbook funcionara). Named Locations requiere ambos permisos.
Solución: Añadir Policy.Read.All Application a 250c7cb4 + Admin Consent.

6 — Servicio no se reiniciaba tras deploy

Síntoma: Restart-Service TravelCAApprover fallaba con "cannot be stopped". El servicio también era indetenible desde la UI de Servicios de Windows.
Causa: Proceso Python bloqueado/colgado que WinSW no podía terminar limpiamente.
Solución: Matar el proceso por puerto y reiniciar:
$procId = (Get-NetTCPConnection -LocalPort 8091 -State Listen).OwningProcess
Stop-Process -Id $procId -Force
Start-Service TravelCAApprover
Nota: $pid es read-only en PowerShell, usar $procId u otro nombre.

7 — KeyError 'jira' en jira_client

Causa: Las funciones internas esperaban cfg['jira']['base_url'] pero recibían ya el sub-config (cfg.get('jira', {})), causando KeyError al intentar acceder a cfg['jira'] dentro del módulo.
Solución: Las funciones internas leen cfg['base_url'] directamente. Convención: los módulos reciben siempre su propio sub-config, no el config completo.

8 — HTTP 400 al crear etiqueta Jira con espacios

Causa: Jira no permite espacios en etiquetas.
Solución: Etiqueta cambiada a Travel-CA-Exception.

9 — AttributeError: sqlite3.Row has no .get()

Síntoma: AttributeError: 'sqlite3.Row' object has no attribute 'get' en exec_auto_approve.
Causa: sqlite3.Row no implementa el método .get() — solo soporta indexación con []. El código usaba exc.get("jira_key").
Solución: Cambiar a exc["jira_key"] if exc["jira_key"] else None.

12. Integración Jira — resumen completo

Proyecto Jira: SIS. Etiqueta: Travel-CA-Exception.

Evento Modo Función(es) Tickets afectados
Nueva solicitud Con aprobación create_enable_ticket + link_issues + add_label SIS nuevo (creado) + SOS (etiquetado y enlazado)
Nueva solicitud Autoservicio Sin tickets SIS. El campo jira_key queda vacío.
Activación (enable) Con aprobación add_approval_comment SOS + ticket SIS de activación
Activación (enable) Autoservicio add_approval_comment (sin sis_ticket_key) Solo SOS
Desactivación manual Con aprobación add_disable_comment SOS + ticket SIS de activación
Desactivación manual Autoservicio add_disable_comment (sin sis_ticket_key) Solo SOS
Cancelación Ambos add_cancel_comment Solo SOS
Expiración (scheduler) Con aprobación create_expiry_ticket + link_issues × N SIS nuevo (tabla de vencidas) + SIS de activación de cada excepción
Expiración (scheduler) Autoservicio add_disable_comment por excepción Solo SOS de cada excepción. Sin ticket SIS nuevo.

13. Estado del proyecto

Proyecto completo y en producción. Todos los flujos verificados end-to-end en ambos modos.

14. Configuración de referencia

IDs fijos del tenant

NombreIDDescripción
RiskyCountriesLocId1f753369-b550-4df1-9811-60709007b8c6Named Location #04 - Risky Countries
TravelGroupId9ef82868-2d3f-4abd-96eb-cfbc3783addcGrupo CA - Travel Exceptions
MainCAPolicyId9d0adb2b-ca25-4278-80ec-c627b3611bf8CA policy Risky Countries - 01 - Default (referencia, no se modifica)
ManagedIdentityClientIde7555954-c0d0-4d8d-8fa5-fdb5fcccbe9bUAMI uami-sisscripts (solo en runbook)

Tenant

CampoValor
Tenant IDd78b7929-c2a3-4897-ae9a-7d8f8dc1a1cf
Dominioidealista.com

App Registration — Travel CA Approver (auth delegada)

CampoValor
client_id6a0be082-1ff1-45ab-ad23-16dfc08db20f
redirect_urihttps://travelcaapprover-idealistaa82505660.msappproxy.net/auth/callback
app_base_urlhttps://travelcaapprover-idealistaa82505660.msappproxy.net

Permisos delegados

PermisoPara qué
Policy.ReadWrite.ConditionalAccessCrear/eliminar Named Locations y CA policies (auth code flow)
Policy.Read.AllLeer políticas CA existentes
Group.ReadWrite.AllAñadir/quitar usuario del grupo CA - Travel Exceptions
User.Read.AllResolver UPN en llamadas delegadas

Permisos Application (client credentials, admin consent)

PermisoPara qué
User.Read.AllResolver UPN → Id + DisplayName en background
AppRoleAssignment.ReadWrite.AllLeer roles del usuario desde appRoleAssignedTo
GroupMember.Read.AllLeer miembros de grupos para autocompletado

App Registration — SisScripts (daemon token para autoservicio)

CampoValor
client_id250c7cb4-c8c7-4f9c-a393-5aa0b057dcc4
AutenticaciónCertificado — thumbprint FC402921656C6BFE606980EB654C7A507883239E (almacén CurrentUser\My)
Compartida consecurity-monitor, spo-permissions-hub y otros proyectos del servidor sisapps

Permisos Application (admin consent)

PermisoPara qué
Policy.ReadWrite.ConditionalAccessCrear/eliminar Named Locations y CA policies (modo autoservicio)
Policy.Read.AllRequerido junto al anterior para Named Locations — sin él, 403
Group.ReadWrite.AllAñadir/quitar usuario del grupo CA - Travel Exceptions
User.Read.AllResolver UPN → Id y DisplayName

Configuración app-config.json (extracto)

{
  "port": 8091,
  "auth": {
    "client_id": "6a0be082-1ff1-45ab-ad23-16dfc08db20f",
    "client_secret": "...",
    "tenant_id": "d78b7929-c2a3-4897-ae9a-7d8f8dc1a1cf",
    "redirect_uri": "https://travelcaapprover-idealistaa82505660.msappproxy.net/auth/callback",
    "app_base_url": "https://travelcaapprover-idealistaa82505660.msappproxy.net"
  },
  "daemon_graph": {
    "tenant_id": "d78b7929-c2a3-4897-ae9a-7d8f8dc1a1cf",
    "client_id": "250c7cb4-c8c7-4f9c-a393-5aa0b057dcc4",
    "auth_mode": "certificate",
    "certificate_thumbprint": "FC402921656C6BFE606980EB654C7A507883239E"
  },
  "graph": {
    "travel_group_id": "9ef82868-2d3f-4abd-96eb-cfbc3783addc",
    "risky_countries_location_id": "1f753369-b550-4df1-9811-60709007b8c6",
    "users_group_ids": [
      "3e000190-079a-4ac2-9dc2-86dbc397a5a5",
      "..."
    ]
  },
  "jira": {
    "enabled": true,
    "base_url": "https://idealista.atlassian.net",
    "user_email": "[email protected]",
    "api_token": "...",
    "project_key": "SIS"
  },
  "scheduler": {
    "check_hour": 8,
    "token_expire_hours_enable": 24,
    "token_expire_hours_disable": 336
  },
  "sso": {
    "dev_bypass_upn": ""
  }
}

Convención de nombres de objetos creados en Entra ID

ObjetoPatrón de nombreEjemplo
Named Location Travel | {DisplayName} | {países} | hasta {YYYY-MM-DD} Travel | Juan García | FR, IT | hasta 2026-09-01
CA Policy Travel | {UPN} | {países} | hasta {YYYY-MM-DD} Travel | [email protected] | FR, IT | hasta 2026-09-01

Servicio WinSW

CampoValor
Nombre del servicioTravelCAApprover
XML de configuracióntravel-ca-approver\travel-ca-approver-service.xml
Servidorsisapps (Windows Server)
Deploy.\deploy.ps1 — git pull + pip install + restart service
Logstravel-ca-approver\logs\ (RotatingFileHandler)
Forzar reinicio si falla$procId = (Get-NetTCPConnection -LocalPort 8091 -State Listen).OwningProcess; Stop-Process -Id $procId -Force