PowerShell Microsoft Graph API Entra ID CA Python Flask En producción
| Campo | Valor |
|---|---|
| Nombre | Risky Countries - 01 - Default |
| ID | 9d0adb2b-ca25-4278-80ec-c627b3611bf8 |
| Lógica | Bloquea 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 |
| excludeGroups | Grupos existentes + CA - Travel Exceptions (nuevo, para los viajeros) |
| Campo | Valor |
|---|---|
| Nombre | #0002 - Global - Protected Actions CA |
| Efecto original | Asignaba el auth context c1 a las operaciones de escritura CA, exigiendo acrs=c1 en el token. |
| Estado actual | Las operaciones namedLocations/create|delete y conditionalAccessPolicies/create|delete han sido eliminadas del auth context, permitiendo que un daemon token (client credentials) las ejecute directamente. |
| Impacto | El modo con aprobación (delegado) sigue funcionando igual. El modo autoservicio (daemon/SisScripts) puede operar sin MFA step-up. |
| Elemento | Nombre | ID | Propó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. |
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).
Ruta: scripts_sistemas\windows\AzureAD\CA\Manage-TravelCAException.ps1
Install-Module Microsoft.Graph -Scope CurrentUser
# 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ámetro | Obligatorio | Descripción |
|---|---|---|
-Action | Sí | Enable, Disable o List |
-UPN | Sí | UPN del usuario viajero (p. ej. [email protected]) |
-CountryCode | Solo Enable | Códigos ISO 3166-1 alpha-2 separados por coma. Ej: FR o FR,IT,DE |
-EndDate | Solo Enable | Fecha de fin del viaje en formato YYYY-MM-DD. Debe ser futura. |
Delegada interactiva: Connect-MgGraph abre el navegador para login. Scopes requeridos: Policy.ReadWrite.ConditionalAccess, Policy.Read.All, Group.ReadWrite.All, User.Read.All.
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.
CountryCode (formato ISO 3166-1 alpha-2, uno o varios) y EndDate (fecha futura).GET /users/{UPN} — obtener Id y DisplayName.POST /identity/conditionalAccess/namedLocations — crear countryNamedLocation con los países de destino.POST /identity/conditionalAccess/policies — crear CA policy individual (con reintentos por error 1040).POST /groups/{TravelGroupId}/members/$ref — añadir usuario al grupo CA - Travel Exceptions.PolicyId y LocationId.DELETE /identity/conditionalAccess/policies/{PolicyId}.DELETE /identity/conditionalAccess/namedLocations/{LocationId} — con reintentos por error 1178.DELETE /groups/{TravelGroupId}/members/{userId}/$ref.try/catch independiente para que un error parcial no impida limpiar el resto.
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:
Travel | DisplayName | FR, IT, DE | hasta YYYY-MM-DD, lo que permite identificar de un vistazo el alcance.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.
POST /identity/conditionalAccess/policies devuelve 400 Bad Request con código 1040: "NamedLocation does not exist in the directory".POST /groups/.../members/$ref falla indicando que el usuario ya es miembro.try/catch que comprueba si el error contiene "already exist" y lo ignora silenciosamente.
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".[switch] falla en Azure Automation[switch] como la cadena "True", no como valor booleano.
[switch]$UseManagedIdentity por [bool]$UseManagedIdentity = $false.
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.
| Elemento | Detalle |
|---|---|
| Automation Account | SISScripts — resource group rg-sistemas |
| Identidad | UAMI uami-sisscripts — Client ID e7555954-c0d0-4d8d-8fa5-fdb5fcccbe9b |
| Auth en runbook | Connect-MgGraph -Identity -ClientId $ManagedIdentityClientId -NoWelcome |
| Ejecución | Azure sandbox (no Hybrid Worker — ver problemas abajo) |
| Disable sin IDs | Busca la CA policy activa por nombre (Travel | $UPN |*) y extrae el LocationId de conditions.locations.excludeLocations[0] |
| Permiso | Tipo | Para qué |
|---|---|---|
User.Read.All | Application | Resolver UPN → Id y DisplayName |
Policy.Read.All | Application | Leer CA policies existentes (Disable) |
Policy.ReadWrite.ConditionalAccess | Application | Crear/eliminar Named Locations y CA policies |
Group.ReadWrite.All | Application | Añadir/quitar usuario del grupo Travel Exceptions |
-ClientId no soportadaConnect-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".403 — POST namedLocations: required scopes are missing in the tokennamedLocations/create|delete, conditionalAccessPolicies/create|delete#0002 - Global - Protected Actions CA asignaba el auth context c1 a estas operaciones.
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.
| Opción | Resultado |
|---|---|
Desactivar #0002 globalmente | Funciona, 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 #0002 | No viable — la UAMI (tipo ManagedIdentity SP) no aparece en el picker de Enterprise Apps |
| Microsoft Entra Workload ID Premium | Confirmado 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. |
#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.Policy.Read.All faltante250c7cb4) seguía devolviendo 403 al crear Named Locations.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.
Policy.Read.All como permiso Application a 250c7cb4 y hacer Admin Consent. El 403 desapareció inmediatamente.
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.
Ruta: jmfernandez\travel-ca-approver\ — Puerto 8091 — WinSW en servidor sisapps
| Elemento | Detalle |
|---|---|
| Stack | Python 3 + Flask + Waitress (WSGI), SQLite WAL, APScheduler |
| Servicio | WinSW (TravelCAApprover) en servidor sisapps |
| Puerto | 8091 |
| App Proxy | https://travelcaapprover-idealistaa82505660.msappproxy.net |
| App Proxy pre-auth | Microsoft 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 |
| Roles | Requester (helpdesk) y Approver (sysadmin) — Enterprise App 6a0be082 |
| Tokens de acción | UUID de un solo uso en SQLite con expires_at |
| Toggle de modo | Setting approval_required en tabla SQLite settings — Approver lo cambia desde el dashboard |
| Jira | Integración condicional al modo activo (ver sección 12) |
| Scheduler | APScheduler diario 08:00 Europe/Madrid + trigger manual desde dashboard |
| Deploy | deploy.ps1 — git pull + pip install + restart service |
| Rol | Asignado a | Acceso |
|---|---|---|
Requester | Equipo helpdesk | /request — crear solicitud |
Approver | Equipo sysadmin | Dashboard completo, toggle modo, scheduler manual, aprobar/deshabilitar |
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.
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.
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 (🔒 / ⚡).
approval_required = true)| Evento | Qué ocurre |
|---|---|
| Nueva solicitud | Crea registro pending en DB + token UUID + incidencia SIS en Jira enlazada a la incidencia SOS |
| Activación | Admin 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 manual | Genera token de disable, redirige a la página de aprobación con el mismo claims challenge flow. Comenta en SOS y en SIS. |
| Scheduler | Por 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. |
approval_required = false)| Evento | Qué ocurre |
|---|---|
| Nueva solicitud | Activa 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ón | Inmediata, sin intervención del Approver. Comenta solo en la incidencia SOS. El campo jira_key (SIS) queda vacío. |
| Desactivación manual | El Approver pulsa "Deshabilitar" en el dashboard → spinner → daemon token ejecuta las 3 operaciones Graph de disable. Comenta solo en SOS. |
| Scheduler | Por cada excepción vencida: llama directamente a graph.disable() con daemon token, marca como disabled en DB, comenta en SOS. Sin ticket SIS. |
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.
/request. Rellena incidencia SOS, UPN (autocompletado desde grupos configurados), países de destino (selector multi-país), y fecha de fin del viaje.status: pending. Genera token UUID de aprobación (24 h de expiración).Travel-CA-Exception al ticket SOS./approve?token=UUID desde el ticket SIS.acrs).POST namedLocations. Si Graph devuelve claims challenge, parsea el challenge y redirige al admin para MFA step-up.acrs=c1 (o directamente si no hay Protected Actions en estas operaciones): ejecuta las 4 operaciones Graph.active en SQLite. Jira: comentario en SOS y en SIS./approve?token=UUID (token de disable).disabled. Jira: comentario en SOS y en SIS.pending (no activadas aún).cancelled en SQLite. Jira: comentario en SOS.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:
Si approval_required = false (autoservicio):
250c7cb4 + certificado).graph.disable() directamente, marca como disabled en SQLite, añade comentario a la incidencia SOS.X-MS-CLIENT-PRINCIPAL-NAME.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).
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.
User.Read.All como permiso Application (solo existía el delegado).
User.Read.All Application + Admin Consent.GET /groups/{id}/members requería GroupMember.Read.All Application. Solo estaba Group.ReadWrite.All delegado.
GroupMember.Read.All Application + Admin Consent.250c7cb4 tenía Policy.ReadWrite.ConditionalAccess con admin consent y el token lo confirmaba, pero Graph devolvía 403 con "required scopes are missing".Policy.Read.All como permiso Application. La UAMI e7555954 sí lo tenía (de ahí que el runbook funcionara). Named Locations requiere ambos permisos.
Policy.Read.All Application a 250c7cb4 + Admin Consent.Restart-Service TravelCAApprover fallaba con "cannot be stopped". El servicio también era indetenible desde la UI de Servicios de Windows.$procId = (Get-NetTCPConnection -LocalPort 8091 -State Listen).OwningProcess Stop-Process -Id $procId -Force Start-Service TravelCAApproverNota:
$pid es read-only en PowerShell, usar $procId u otro nombre.
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.
cfg['base_url'] directamente. Convención: los módulos reciben siempre su propio sub-config, no el config completo.
Travel-CA-Exception.AttributeError: 'sqlite3.Row' object has no attribute 'get' en exec_auto_approve.sqlite3.Row no implementa el método .get() — solo soporta indexación con []. El código usaba exc.get("jira_key").
exc["jira_key"] if exc["jira_key"] else None.
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. |
Manage-TravelCAException.ps1 funcional (Enable / Disable / List)uami-sisscripts creada, permisos Graph asignadosInvoke-TravelCAException.ps1 — válido en Azure sandbox#0002 para permitir daemon tokenstravel-ca-approver en producción (WinSW TravelCAApprover, App Proxy)appRoleAssignedTo)User.Read.All + GroupMember.Read.All + Policy.Read.All (en SisScripts)sqlite3.Row no tiene .get() — indexación directa con []| Nombre | ID | Descripción |
|---|---|---|
RiskyCountriesLocId | 1f753369-b550-4df1-9811-60709007b8c6 | Named Location #04 - Risky Countries |
TravelGroupId | 9ef82868-2d3f-4abd-96eb-cfbc3783addc | Grupo CA - Travel Exceptions |
MainCAPolicyId | 9d0adb2b-ca25-4278-80ec-c627b3611bf8 | CA policy Risky Countries - 01 - Default (referencia, no se modifica) |
ManagedIdentityClientId | e7555954-c0d0-4d8d-8fa5-fdb5fcccbe9b | UAMI uami-sisscripts (solo en runbook) |
| Campo | Valor |
|---|---|
| Tenant ID | d78b7929-c2a3-4897-ae9a-7d8f8dc1a1cf |
| Dominio | idealista.com |
| Campo | Valor |
|---|---|
| client_id | 6a0be082-1ff1-45ab-ad23-16dfc08db20f |
| redirect_uri | https://travelcaapprover-idealistaa82505660.msappproxy.net/auth/callback |
| app_base_url | https://travelcaapprover-idealistaa82505660.msappproxy.net |
| Permiso | Para qué |
|---|---|
Policy.ReadWrite.ConditionalAccess | Crear/eliminar Named Locations y CA policies (auth code flow) |
Policy.Read.All | Leer políticas CA existentes |
Group.ReadWrite.All | Añadir/quitar usuario del grupo CA - Travel Exceptions |
User.Read.All | Resolver UPN en llamadas delegadas |
| Permiso | Para qué |
|---|---|
User.Read.All | Resolver UPN → Id + DisplayName en background |
AppRoleAssignment.ReadWrite.All | Leer roles del usuario desde appRoleAssignedTo |
GroupMember.Read.All | Leer miembros de grupos para autocompletado |
| Campo | Valor |
|---|---|
| client_id | 250c7cb4-c8c7-4f9c-a393-5aa0b057dcc4 |
| Autenticación | Certificado — thumbprint FC402921656C6BFE606980EB654C7A507883239E (almacén CurrentUser\My) |
| Compartida con | security-monitor, spo-permissions-hub y otros proyectos del servidor sisapps |
| Permiso | Para qué |
|---|---|
Policy.ReadWrite.ConditionalAccess | Crear/eliminar Named Locations y CA policies (modo autoservicio) |
Policy.Read.All | Requerido junto al anterior para Named Locations — sin él, 403 |
Group.ReadWrite.All | Añadir/quitar usuario del grupo CA - Travel Exceptions |
User.Read.All | Resolver UPN → Id y DisplayName |
{
"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": ""
}
}
| Objeto | Patrón de nombre | Ejemplo |
|---|---|---|
| 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 |
| Campo | Valor |
|---|---|
| Nombre del servicio | TravelCAApprover |
| XML de configuración | travel-ca-approver\travel-ca-approver-service.xml |
| Servidor | sisapps (Windows Server) |
| Deploy | .\deploy.ps1 — git pull + pip install + restart service |
| Logs | travel-ca-approver\logs\ (RotatingFileHandler) |
| Forzar reinicio si falla | $procId = (Get-NetTCPConnection -LocalPort 8091 -State Listen).OwningProcess; Stop-Process -Id $procId -Force |