Cómo inventariar Service Principals en Azure y ligarlos a proyectos y repositorios
Daniel J. Saldaña- 20 de julio de 2026
- Puntuación de feedback

Los registros de aplicaciones, los Service Principals y las identidades administradas forman parte del día a día de cualquier plataforma que trabaje con Azure.
Al principio es fácil mantenerlos bajo control. Hay pocos pipelines, pocas integraciones y todo el equipo recuerda para qué sirve cada identidad. Sin embargo, a medida que crece la plataforma, empiezan a aparecer preguntas difíciles de responder:
- ¿Qué aplicación utiliza esta identidad?
- ¿A qué proyecto pertenece?
- ¿Qué repositorio la despliega?
- ¿En qué entorno se usa?
- ¿Quién es el responsable?
- ¿Qué ocurre si mañana caduca uno de sus secretos?
Microsoft Entra permite consultar identidades y asignar propietarios. Azure RBAC muestra qué permisos tiene cada identidad sobre los recursos de Azure. Pero gran parte del contexto operativo suele vivir fuera de ambos sistemas: en GitHub, Azure DevOps, una base de datos interna, una hoja de cálculo o, en el peor de los casos, en la memoria del equipo.
El problema no suele ser descubrir que una identidad existe. El problema es entender rápidamente qué depende de ella y quién debe actuar cuando algo falla.
La buena noticia es que no hace falta construir una gran plataforma de gobierno para mejorar esta situación. Con un sistema pequeño puedes cubrir lo esencial:
- Descubrir las identidades existentes en Microsoft Entra.
- Revisar si están activas.
- Detectar secretos y certificados próximos a caducar.
- Relacionar cada identidad con un proyecto, repositorio, equipo o entorno.
- Identificar identidades activas que nadie reconoce como propias.
En este artículo veremos cómo plantear ese sistema paso a paso utilizando Microsoft Graph, el SDK de identidad de Azure y una base de datos sencilla para almacenar el contexto operativo.
Un escenario que probablemente te resulte familiar
Son las nueve de un lunes y uno de los pipelines de producción empieza a fallar con el siguiente error:
AADSTS7000222: The provided client secret keys are expired.Abres Microsoft Entra, buscas el client ID que aparece en el log y encuentras la Enterprise Application asociada.
Hasta ahí, todo bien.
El problema empieza cuando intentas responder a la siguiente pregunta:
¿Qué repositorio está utilizando este Service Principal?
Quizá sea el repositorio de pagos, el de facturación o una integración antigua que nadie ha modificado durante dos años. La identidad puede tener propietarios asignados en Entra, pero eso no indica necesariamente qué workflow utiliza el secreto, qué entorno depende de él o qué servicio dejará de funcionar.
Empieza entonces una investigación manual:
- Buscar el client ID en GitHub.
- Revisar secretos de organizaciones y repositorios.
- Consultar documentación interna.
- Preguntar en Slack o Teams.
- Examinar pipelines antiguos.
- Intentar localizar a la persona que creó la identidad.
La renovación del secreto puede llevar cinco minutos. Descubrir dónde se utiliza puede llevar varias horas.
Ese vacío entre la identidad técnica y el contexto operativo es el que queremos cerrar.
Tres conceptos que conviene no mezclar
Antes de construir el inventario, es importante diferenciar varias piezas que habitualmente se agrupan bajo expresiones como «la aplicación» o «el SPN».
| Concepto | Qué representa | Dónde consultarlo |
|---|---|---|
| App Registration | La definición global de una aplicación: permisos, URI, credenciales y configuración de autenticación | GET /applications en Microsoft Graph |
| Enterprise Application | La instancia de una aplicación dentro de un tenant | GET /servicePrincipals |
| Service Principal | La identidad no humana utilizada para autenticarse y obtener tokens | Es el mismo objeto que la Enterprise Application |
La relación habitual es la siguiente:
flowchart TD App[App Registration] SP[Enterprise Application / Service Principal] Consumers[Pipelines, APIs, scripts e integraciones]
App -->|appId / client ID| SP SP --> ConsumersLa App Registration contiene la definición de la aplicación. El Service Principal representa esa aplicación dentro de un tenant concreto.
El campo que normalmente conecta ambos objetos es appId, conocido también como client ID.
Un pipeline de GitHub Actions, un backend desplegado fuera de Azure o una integración con un SaaS suelen autenticarse utilizando ese client ID junto con un secreto, un certificado o una identidad federada.
Sin embargo, no todos los Service Principals tienen una App Registration local.
Cuando instalas una aplicación SaaS, utilizas una aplicación multi-tenant o consumes determinados servicios publicados por Microsoft, puede existir un Service Principal en tu tenant sin que aparezca una App Registration equivalente en /applications.
Por eso, un inventario que consulte únicamente App Registrations estará incompleto.
Qué información no ofrece Entra por sí solo
Microsoft Entra conoce la identidad, sus credenciales, su estado y sus propietarios administrativos. Pero normalmente no conoce el contexto completo en el que esa identidad se utiliza.
Por ejemplo, Entra no responde directamente a preguntas como:
- Este Service Principal pertenece al proyecto
Billing. - Esta identidad la utiliza el repositorio
company/payments-api. - Este client ID se usa únicamente en producción.
- El equipo responsable es
Platform Payments. - La identidad puede eliminarse cuando se retire un determinado servicio.
- El secreto está almacenado en los secrets de un repositorio concreto.
Los propietarios de Entra indican quién puede administrar una identidad, pero no siempre quién conoce el servicio que depende de ella.
Tampoco existe un recurso ARM natural sobre el que añadir etiquetas de proyecto, repositorio o producto cuando hablamos de Service Principals.
Por eso es útil mantener dos capas separadas:
- Inventario técnico, obtenido desde Microsoft Graph.
- Contexto operativo, almacenado en una base de datos controlada por tu organización.
Managed Identity primero; Service Principal cuando sea necesario
Antes de empezar a inventariar secretos, conviene recordar que no todos los escenarios deberían utilizar uno.
Siempre que sea posible, las Managed Identities y la Workload Identity Federation deberían ser la opción preferente. Reducen la necesidad de almacenar credenciales de larga duración y eliminan gran parte del trabajo manual de rotación.
| Escenario | Opción recomendada | Motivo |
|---|---|---|
| App Service o Azure Function accede a Storage o Key Vault | Managed Identity | No requiere secretos gestionados por el equipo |
| Pod de AKS accede a recursos de Azure | Workload Identity o identidad asignada por el usuario | Integración nativa con la identidad del workload |
| GitHub Actions despliega en Azure | Workload Identity Federation mediante OIDC | Evita almacenar un client secret en GitHub |
| Runner local o desplegado en otra nube | Service Principal con certificado o secreto | No existe un recurso de Azure que pueda emitir la identidad |
| Integración con un SaaS externo | Enterprise Application | El proveedor controla la aplicación |
| Script legacy con variables de entorno | Service Principal con secreto | Puede requerir una migración progresiva |
Aunque una organización priorice la federación, seguirá teniendo Service Principals tradicionales durante bastante tiempo.
Además, las Managed Identities también aparecen en Microsoft Graph como objetos servicePrincipal, normalmente con:
servicePrincipalType = "ManagedIdentity"Por tanto, el inventario debería incluirlas. No necesitan una alerta de rotación manual, pero sí puede ser útil conocer:
- Qué identidades existen.
- Si siguen activas.
- Qué recurso las creó.
- Si tienen asignaciones RBAC.
- Si continúan teniendo un propietario operativo.
Qué debería hacer el sistema
No es necesario empezar con una aplicación compleja.
Una primera versión puede dividirse en dos capas:
flowchart LR subgraph Azure["Inventario técnico"] Graph[Microsoft Graph] Fetcher[Proceso de descubrimiento] Cache[(Inventario almacenado)] end
subgraph Context["Contexto operativo"] Projects[Proyectos] Repositories[Repositorios] Teams[Equipos] Assignments[(Asignaciones)] end
subgraph Usage["Consumo"] API[API] UI[Panel o informe] Alerts[Alertas] end
Graph --> Fetcher Fetcher --> Cache
Cache --> Assignments Projects --> Assignments Repositories --> Assignments Teams --> Assignments
Cache --> API Assignments --> API API --> UI API --> AlertsLa primera capa responde:
¿Qué identidades existen y en qué estado se encuentran?
La segunda responde:
¿A qué proyecto, repositorio, entorno o equipo pertenece cada identidad?
Juntas permiten responder una tercera pregunta mucho más útil:
¿Quién tiene que actuar cuando una credencial está a punto de caducar?
Paso 1: obtener acceso a Microsoft Graph
Para consultar las aplicaciones y los Service Principals del tenant puedes utilizar @azure/identity.
Una implementación básica con ClientSecretCredential sería la siguiente:
import { ClientSecretCredential } from "@azure/identity";
const tenantId = process.env.AZURE_TENANT_ID;const clientId = process.env.AZURE_CLIENT_ID;const clientSecret = process.env.AZURE_CLIENT_SECRET;
if (!tenantId || !clientId || !clientSecret) { throw new Error( "Faltan AZURE_TENANT_ID, AZURE_CLIENT_ID o AZURE_CLIENT_SECRET", );}
export const azureCredential = new ClientSecretCredential( tenantId, clientId, clientSecret,);Para obtener un token de Microsoft Graph:
const GRAPH_SCOPE = "https://graph.microsoft.com/.default";
const tokenResponse = await azureCredential.getToken(GRAPH_SCOPE, { abortSignal: AbortSignal.timeout(15_000),});
if (!tokenResponse?.token) { throw new Error("No se pudo obtener un token de Microsoft Graph");}El Service Principal que ejecute este inventario debe ser una identidad de lectura independiente de las identidades que se están inventariando.
Para consultar aplicaciones y Service Principals necesita normalmente:
Application.Read.AllDebe configurarse como permiso de aplicación y recibir consentimiento de administrador.
Si Graph devuelve un error 403, conviene mostrar un mensaje explícito:
if (response.status === 403) { throw new Error( "Microsoft Graph no permite leer las identidades. " + "Concede Application.Read.All como permiso de aplicación " + "y aplica consentimiento de administrador.", );}Evita transformar todos los errores en un mensaje genérico como «Azure no está disponible». Una configuración incorrecta debe poder distinguirse de un problema de red o de throttling.
Autenticación diferida para entornos locales
Si el inventario forma parte de una aplicación más grande, quizá no quieras que toda la aplicación deje de arrancar cuando Azure no está configurado.
En ese caso puedes crear la credencial solo cuando se utilice:
import { ClientSecretCredential } from "@azure/identity";
let credential: ClientSecretCredential | null = null;
function buildCredential(): ClientSecretCredential { const tenantId = process.env.AZURE_TENANT_ID; const clientId = process.env.AZURE_CLIENT_ID; const clientSecret = process.env.AZURE_CLIENT_SECRET;
if (!tenantId || !clientId || !clientSecret) { throw new Error("El proveedor Azure no está configurado"); }
return new ClientSecretCredential(tenantId, clientId, clientSecret);}
export function getAzureCredential(): ClientSecretCredential { credential ??= buildCredential(); return credential;}Así, una persona puede trabajar en otras partes de la aplicación sin disponer necesariamente de credenciales de Azure.
El panel de identidades puede mostrarse como no configurado o degradado, mientras el resto del sistema sigue funcionando.
Paso 2: descubrir todas las identidades del tenant
Uno de los errores más frecuentes es consultar únicamente:
GET /applicationsEso deja fuera:
- Enterprise Applications de terceros.
- Aplicaciones de catálogo.
- Service Principals sin una App Registration local.
- Managed Identities.
- Algunas identidades antiguas o creadas por integraciones externas.
Para construir un inventario completo deberías consultar al menos:
GET /applicationsGET /servicePrincipalsPuedes hacer ambas llamadas en paralelo:
const [applications, servicePrincipals] = await Promise.all([ listGraphPages<GraphApplication>( graphUrl( "applications", [ "id", "appId", "displayName", "createdDateTime", "signInAudience", "passwordCredentials", "keyCredentials", ].join(","), ), token, ),
listGraphPages<GraphServicePrincipal>( graphUrl( "servicePrincipals", [ "id", "appId", "displayName", "accountEnabled", "servicePrincipalType", "appOwnerOrganizationId", "createdDateTime", "passwordCredentials", "keyCredentials", ].join(","), ), token, ),]);Una función sencilla para construir las URLs puede ser:
function graphUrl(resource: string, select: string): string { const url = new URL(`https://graph.microsoft.com/v1.0/${resource}`); url.searchParams.set("$select", select); return url.toString();}Los campos más relevantes son:
passwordCredentials: secretos asociados.keyCredentials: certificados asociados.servicePrincipalType: permite identificar Managed Identities.appOwnerOrganizationId: indica qué tenant controla la aplicación.accountEnabled: muestra si la identidad está habilitada.signInAudience: diferencia aplicaciones single-tenant y multi-tenant.createdDateTime: ayuda a detectar identidades antiguas o creadas recientemente.appId: client ID utilizado por aplicaciones y pipelines.id: object ID del objeto concreto en el tenant.
Gestionar la paginación de Microsoft Graph
Microsoft Graph devuelve resultados paginados mediante @odata.nextLink.
Una función reutilizable podría tener esta forma:
interface GraphPage<T> { value?: T[]; "@odata.nextLink"?: string;}
async function listGraphPages<T>( initialUrl: string, token: string,): Promise<T[]> { const results: T[] = []; let nextUrl: string | null = initialUrl;
while (nextUrl) { const parsedUrl = new URL(nextUrl);
if (parsedUrl.hostname !== "graph.microsoft.com") { throw new Error("Microsoft Graph devolvió una URL de paginación no válida"); }
const response = await fetch(nextUrl, { headers: { Authorization: `Bearer ${token}`, Accept: "application/json", }, signal: AbortSignal.timeout(15_000), });
if (!response.ok) { const responseBody = await response.text();
throw new Error( `Error consultando Microsoft Graph: ${response.status} ${responseBody}`, ); }
const page = (await response.json()) as GraphPage<T>;
if (Array.isArray(page.value)) { results.push(...page.value); }
nextUrl = page["@odata.nextLink"] ?? null; }
return results;}Validar que nextLink pertenece a graph.microsoft.com evita seguir ciegamente una URL inesperada.
También es recomendable establecer un timeout. Un tenant grande puede requerir varias páginas y no debería mantener un proceso bloqueado indefinidamente.
Paso 3: unificar App Registrations y Service Principals
Una misma aplicación puede aparecer en las dos consultas.
La App Registration contiene la definición. El Service Principal representa su instancia dentro del tenant.
Si muestras ambos resultados por separado, duplicarás muchas identidades. Si consultas únicamente uno de los endpoints, perderás información.
La estrategia puede ser:
- Indexar los Service Principals por
appId. - Recorrer las App Registrations.
- Relacionar cada aplicación con su Service Principal.
- Marcar los Service Principals ya utilizados.
- Añadir los Service Principals restantes como Enterprise Applications o Managed Identities.
Un ejemplo simplificado:
interface AzureIdentity { application: GraphApplication | null; servicePrincipal: GraphServicePrincipal | null; kind: | "applicationRegistration" | "enterpriseApplication" | "managedIdentity";}
function buildIdentityInventory( applications: GraphApplication[], servicePrincipals: GraphServicePrincipal[],): AzureIdentity[] { const principalsByAppId = new Map<string, GraphServicePrincipal[]>();
for (const principal of servicePrincipals) { if (!principal.appId) continue;
const current = principalsByAppId.get(principal.appId) ?? []; current.push(principal); principalsByAppId.set(principal.appId, current); }
const identities: AzureIdentity[] = []; const usedPrincipalIds = new Set<string>();
for (const application of applications) { const matchingPrincipals = application.appId ? principalsByAppId.get(application.appId) ?? [] : [];
const servicePrincipal = matchingPrincipals[0] ?? null;
if (servicePrincipal?.id) { usedPrincipalIds.add(servicePrincipal.id); }
identities.push({ application, servicePrincipal, kind: "applicationRegistration", }); }
for (const servicePrincipal of servicePrincipals) { if ( servicePrincipal.id && usedPrincipalIds.has(servicePrincipal.id) ) { continue; }
const managedIdentity = servicePrincipal.servicePrincipalType?.toLowerCase() === "managedidentity";
identities.push({ application: null, servicePrincipal, kind: managedIdentity ? "managedIdentity" : "enterpriseApplication", }); }
return identities;}En tenants complejos puede haber más de un Service Principal asociado al mismo appId, por lo que quizá necesites adaptar la relación a tu caso.
Para una primera versión, almacenar ambos identificadores suele ser suficiente:
applicationObjectIdservicePrincipalObjectIdappId
Tipos de identidad que aparecerán en el inventario
Puedes normalizar los resultados en tres categorías principales:
| Tipo normalizado | Origen habitual | ¿Tiene credenciales rotables? |
|---|---|---|
applicationRegistration | Aplicación creada en tu tenant | Habitualmente sí |
enterpriseApplication | SaaS, aplicación de catálogo o app multi-tenant | Depende del publisher |
managedIdentity | VM, App Service, Function, AKS u otro recurso | No requiere rotación manual |
Esta clasificación facilita mucho la interfaz.
No tiene sentido mostrar una Managed Identity como si tuviera una credencial ausente. Su estado debería ser algo como:
managedUna Enterprise Application de terceros sin credenciales visibles puede marcarse como:
notApplicableY una App Registration propia sin secretos ni certificados puede mostrarse como:
noCredentialsPaso 4: recopilar y deduplicar credenciales
Microsoft Graph puede devolver credenciales en la App Registration, en el Service Principal o en ambos objetos.
Por eso conviene reunirlas en una única lista:
interface AzureCredentialInfo { keyId: string | null; displayName: string | null; type: "secret" | "certificate"; startDateTime: string | null; endDateTime: string | null;}Una función de recopilación podría ser:
function collectCredentials( application: GraphApplication | null, servicePrincipal: GraphServicePrincipal | null,): AzureCredentialInfo[] { const credentials: AzureCredentialInfo[] = [];
for (const credential of application?.passwordCredentials ?? []) { credentials.push({ keyId: credential.keyId ?? null, displayName: credential.displayName ?? null, type: "secret", startDateTime: credential.startDateTime ?? null, endDateTime: credential.endDateTime ?? null, }); }
for (const credential of application?.keyCredentials ?? []) { credentials.push({ keyId: credential.keyId ?? null, displayName: credential.displayName ?? null, type: "certificate", startDateTime: credential.startDateTime ?? null, endDateTime: credential.endDateTime ?? null, }); }
for (const credential of servicePrincipal?.passwordCredentials ?? []) { credentials.push({ keyId: credential.keyId ?? null, displayName: credential.displayName ?? null, type: "secret", startDateTime: credential.startDateTime ?? null, endDateTime: credential.endDateTime ?? null, }); }
for (const credential of servicePrincipal?.keyCredentials ?? []) { credentials.push({ keyId: credential.keyId ?? null, displayName: credential.displayName ?? null, type: "certificate", startDateTime: credential.startDateTime ?? null, endDateTime: credential.endDateTime ?? null, }); }
return deduplicateCredentials(credentials);}La deduplicación puede hacerse por keyId:
function deduplicateCredentials( credentials: AzureCredentialInfo[],): AzureCredentialInfo[] { const unique = new Map<string, AzureCredentialInfo>();
for (const credential of credentials) { const fallbackKey = [ credential.type, credential.displayName ?? "", credential.startDateTime ?? "", credential.endDateTime ?? "", ].join(":");
const key = credential.keyId ? `${credential.type}:${credential.keyId}` : fallbackKey;
if (!unique.has(key)) { unique.set(key, credential); } }
return [...unique.values()];}Sin esta deduplicación, una misma credencial podría producir dos alertas y generar ruido innecesario.
Paso 5: calcular el estado de las credenciales
Microsoft Graph devuelve fechas, pero no proporciona directamente un estado como «caduca en 12 días».
Ese cálculo debe hacerse en tu aplicación.
Puedes definir una ventana de renovación:
const RENEWAL_WINDOW_DAYS = 30;const DAY_IN_MS = 24 * 60 * 60 * 1000;Y calcular el estado de cada credencial:
type CredentialStatus = | "expired" | "expiring" | "valid" | "scheduled" | "unknown";
function parseTimestamp(value: string | null): number | null { if (!value) return null;
const timestamp = Date.parse(value); return Number.isFinite(timestamp) ? timestamp : null;}
function credentialStatus( credential: AzureCredentialInfo, now: number, renewalWindowDays: number,): CredentialStatus { const start = parseTimestamp(credential.startDateTime); const end = parseTimestamp(credential.endDateTime);
if (end === null) { return "unknown"; }
if (end <= now) { return "expired"; }
if (start !== null && start > now) { return "scheduled"; }
const renewalWindowMs = renewalWindowDays * DAY_IN_MS;
if (end - now <= renewalWindowMs) { return "expiring"; }
return "valid";}Después puedes calcular el estado global de una identidad.
| Estado | Significado |
|---|---|
expired | Al menos una credencial ya ha caducado |
expiring | Alguna credencial caduca dentro de la ventana configurada |
valid | Las credenciales están vigentes |
scheduled | Existe una credencial cuya fecha de inicio aún no ha llegado |
noCredentials | La aplicación no tiene secretos ni certificados |
managed | Es una Managed Identity |
notApplicable | No corresponde evaluar credenciales |
unknown | No se han podido interpretar las fechas |
También es útil almacenar:
interface CredentialSummary { total: number; secrets: number; certificates: number; expired: number; expiring: number; valid: number; scheduled: number; unknown: number;}Y añadir a cada identidad:
interface IdentityCredentialHealth { status: string; nextExpirationDate: string | null; nextExpirationDays: number | null; summary: CredentialSummary;}Con estos datos puedes ordenar la interfaz para mostrar primero:
- Identidades deshabilitadas.
- Credenciales caducadas.
- Credenciales próximas a caducar.
- Identidades sin credenciales.
- Identidades sanas.
Una alerta técnica no es suficiente
Imagina que el sistema muestra lo siguiente:
Client ID: a1b2c3d4-...Estado: expiringCaduca en: 15 díasLa alerta es correcta, pero todavía no indica:
- Qué aplicación depende de esa identidad.
- Qué repositorio utiliza el client ID.
- En qué entorno se almacena el secreto.
- Qué equipo debe renovarlo.
- Qué servicio podría verse afectado.
Por eso el inventario técnico debe conectarse con una capa de contexto operativo.
Paso 6: guardar el inventario
Consultar Microsoft Graph cada vez que una persona abre el panel no es una buena estrategia.
Un tenant puede tener cientos o miles de Service Principals. Además de aumentar la latencia, las consultas frecuentes pueden provocar throttling.
Para un sistema pequeño tienes varias opciones:
- Guardar el inventario en MongoDB.
- Utilizar PostgreSQL.
- Mantener una caché en Redis y una copia persistente en base de datos.
- Ejecutar un proceso programado y almacenar un fichero JSON.
- Utilizar una Azure Function con Timer Trigger.
Una implementación sencilla puede actualizar el inventario cada hora o varias veces al día.
Ejemplo conceptual con MongoDB:
await db.collection("azureIdentityInventory").updateOne( { tenantId: normalizedTenantId, scope: "tenant", }, { $set: { tenantId: normalizedTenantId, scope: "tenant", identities, refreshedAt: new Date(), expiresAt: new Date(Date.now() + ttlMs), }, }, { upsert: true, },);La respuesta de la API debería incluir metadatos como:
{ "refreshedAt": "2026-07-20T08:00:00.000Z", "expiresAt": "2026-07-20T09:00:00.000Z", "stale": false}Así, quien consulte el inventario sabe cuándo se actualizó.
También puedes permitir un refresco manual después de renovar una credencial, evitando que el panel muestre datos obsoletos hasta el siguiente proceso programado.
Paso 7: normalizar cada identidad
Los datos de Microsoft Graph son ricos, pero no siempre están preparados para relacionarse con tus propios proyectos o repositorios.
Conviene transformar cada identidad en un modelo más pequeño y estable:
interface GovernancePrincipal { key: string; provider: "azure"; kind: | "servicePrincipal" | "managedIdentity"; externalId: string; displayName: string; identifier: string; status: "active" | "disabled" | "unknown"; tenantOwned: boolean; appId: string | null; tenantId: string; createdAt: string | null; credentialStatus: string; nextExpirationDate: string | null;}La normalización puede ser:
function normalizeAzurePrincipal( identity: AzureIdentityInsight, tenantId: string,): GovernancePrincipal { const servicePrincipal = identity.servicePrincipal; const application = identity.application;
const externalId = servicePrincipal?.id ?? application?.id ?? identity.appId;
const displayName = servicePrincipal?.displayName?.trim() || application?.displayName?.trim() || "Identidad sin nombre";
const managedIdentity = servicePrincipal?.servicePrincipalType?.toLowerCase() === "managedidentity";
const ownerTenant = servicePrincipal?.appOwnerOrganizationId?.toLowerCase() ?? null;
return { key: `azure:${tenantId}:${externalId}`, provider: "azure", kind: managedIdentity ? "managedIdentity" : "servicePrincipal", externalId, displayName, identifier: identity.appId ?? externalId, status: servicePrincipal?.accountEnabled === true ? "active" : servicePrincipal?.accountEnabled === false ? "disabled" : "unknown", tenantOwned: ownerTenant === null || ownerTenant === tenantId.toLowerCase(), appId: identity.appId ?? null, tenantId, createdAt: servicePrincipal?.createdDateTime ?? application?.createdDateTime ?? null, credentialStatus: identity.credentialStatus, nextExpirationDate: identity.nextExpirationDate, };}La clave:
azure:{tenantId}:{servicePrincipalObjectId}proporciona un identificador estable dentro de tu sistema.
No conviene utilizar únicamente el nombre visible, porque puede cambiar y no tiene por qué ser único.
El appId, en cambio, debe mantenerse como campo de búsqueda porque es el valor que aparecerá con más frecuencia en:
- Logs de autenticación.
- Variables
AZURE_CLIENT_ID. - Workflows de CI/CD.
- Configuraciones de aplicaciones.
- Mensajes de error.
Paso 8: modelar proyectos, repositorios y equipos
La segunda parte del sistema no procede de Microsoft Graph.
Debes definir qué tipos de elementos operativos quieres relacionar con las identidades.
Una primera versión puede utilizar los siguientes ámbitos:
| Tipo de ámbito | Ejemplo | Cuándo utilizarlo |
|---|---|---|
project | Payments Platform | La identidad es compartida por varios repositorios de un producto |
repository | company/payments-api | La identidad pertenece a un pipeline concreto |
organization | company | La identidad se utiliza a nivel de organización |
team | Platform Payments | Un equipo es responsable de su operación |
environment | production | La identidad se utiliza en un entorno específico |
Puedes representar cualquier ámbito con un modelo común:
interface GovernanceScope { key: string; type: | "project" | "repository" | "organization" | "team" | "environment"; id: string; label: string;}Ejemplo para un repositorio:
const repositoryScope: GovernanceScope = { key: `repository:github:${repository.id}`, type: "repository", id: String(repository.id), label: `${repository.owner}/${repository.name}`,};Ejemplo para un proyecto:
const projectScope: GovernanceScope = { key: `project:${project.id}`, type: "project", id: String(project.id), label: project.name,};Los proyectos pueden proceder de tu propia base de datos. Los repositorios pueden cargarse desde GitHub o Azure DevOps.
Para una primera versión incluso podrías mantenerlos manualmente.
Lo importante es que los identificadores sean estables y no dependan únicamente del nombre visible.
Paso 9: crear las asignaciones
Una asignación relaciona una identidad con un ámbito operativo.
interface GovernanceAssignment { principalKey: string; scopeKey: string; environment: string | null; permission: string | null; justification: string; ownerTeam: string | null; createdAt: Date; createdBy: string;}Un ejemplo:
{ "principalKey": "azure:tenant-id:service-principal-object-id", "scopeKey": "repository:github:123456", "environment": "production", "permission": "deploy", "justification": "Utilizado por el workflow de despliegue de payments-api", "ownerTeam": "Platform Payments"}Antes de guardar la asignación deberías validar que la identidad existe en el inventario actual:
const principal = inventory.principals.find( (candidate) => candidate.key === principalKey,);
if (!principal) { throw new Error("La identidad no existe en el inventario actual");}De esta manera evitas crear referencias manuales a Service Principals inexistentes o eliminados.
También es recomendable crear un índice único:
{ principalKey: 1, scopeKey: 1}Así, una identidad no puede asignarse dos veces al mismo ámbito.
Sin embargo, sí puede relacionarse simultáneamente con:
- Un proyecto.
- Un repositorio.
- Un equipo.
- Un entorno.
Eso permite reflejar distintos niveles de propiedad.
Añadir justificación y trazabilidad
La relación entre una identidad y un repositorio no debería limitarse a dos identificadores.
Añadir una justificación aporta mucho contexto:
Utilizado por el workflow deploy-production.yml para publicar la API de pagos.También puedes almacenar:
- El entorno.
- El tipo de acceso.
- La ubicación del secreto.
- La fecha de la última revisión.
- El responsable.
- La fecha prevista de retirada.
- Un enlace al repositorio o al pipeline.
Los cambios deberían registrarse en una bitácora:
interface GovernanceAuditEvent { action: | "assignment_created" | "assignment_updated" | "assignment_deleted"; actor: string; principalKey: string; scopeKey: string; timestamp: Date; metadata: Record<string, unknown>;}No es necesario crear un sistema complejo de auditoría desde el primer día. Una colección o tabla de eventos suele ser suficiente para saber:
- Quién creó la relación.
- Quién la modificó.
- Cuándo cambió el responsable.
- Por qué se eliminó una asignación.
Cómo quedaría el flujo de un incidente
Supongamos que el sistema detecta lo siguiente:
Identidad: payments-api-spnClient ID: a1b2c3d4-...Estado: expiringCaduca en: 12 díasSin contexto operativo, alguien debe investigar dónde se utiliza.
Con las asignaciones, el sistema puede mostrar:
Proyecto: Payments PlatformRepositorio: company/payments-apiEntorno: productionEquipo: Platform PaymentsUso: workflow deploy-production.ymlPermiso: despliegue en AzureEl recorrido del incidente sería:
- El proceso de inventario detecta una credencial próxima a caducar.
- La aplicación busca las asignaciones del Service Principal.
- El equipo de plataforma identifica inmediatamente el repositorio afectado.
- Se rota la credencial en Microsoft Entra.
- Se actualiza el secreto o la configuración del workflow.
- Se ejecuta una validación del despliegue.
- El inventario se actualiza.
- El estado cambia de
expiringavalid.
El tiempo se dedica a resolver el problema, no a descubrir quién utiliza la identidad.
También puedes hacer la consulta en sentido contrario
El sistema no solo debe permitir buscar un Service Principal y ver sus proyectos.
También debería permitir preguntas como:
- ¿Qué identidades utiliza el proyecto Billing?
- ¿Qué Service Principals dependen del repositorio
payments-api? - ¿Cuántas identidades tiene asignadas el equipo de plataforma?
- ¿Qué credenciales de producción caducan durante los próximos 30 días?
- ¿Qué identidades activas no tienen ninguna asignación?
- ¿Qué Managed Identities existen, pero no tienen un responsable?
- ¿Qué Enterprise Applications pertenecen a publishers externos?
Esta capacidad convierte un simple listado en una herramienta operativa.
Identidades huérfanas
Uno de los indicadores más útiles es el número de identidades activas sin ninguna asignación.
Puedes calcularlo fácilmente:
const assignedPrincipalKeys = new Set( assignments.map((assignment) => assignment.principalKey),);
const orphanedPrincipals = principals.filter( (principal) => principal.status === "active" && !assignedPrincipalKeys.has(principal.key),);Una identidad activa y sin propietario operativo puede significar varias cosas:
- Se creó, pero nunca llegó a utilizarse.
- Pertenece a un servicio antiguo.
- La documentación no se actualizó.
- El equipo responsable ya no existe.
- Es una aplicación externa legítima que todavía no se ha clasificado.
- Puede eliminarse o deshabilitarse.
El sistema no debe eliminar identidades automáticamente solo porque estén huérfanas.
Debe convertirlas en elementos que requieren revisión.
Una interfaz mínima
No necesitas construir un gran dashboard desde el principio.
Una primera interfaz puede tener tres vistas.
Inventario
Columnas recomendadas:
- Nombre.
- Tipo.
- Client ID.
- Estado de la identidad.
- Estado de las credenciales.
- Próxima caducidad.
- Número de asignaciones.
- Propiedad del tenant.
- Última actualización.
Detalle de la identidad
Información recomendada:
- App ID.
- Object ID.
- Tenant.
- Tipo de Service Principal.
- Estado.
- Secretos y certificados.
- Fechas de caducidad.
- Proyectos relacionados.
- Repositorios relacionados.
- Equipo responsable.
- Justificación.
- Historial de cambios.
Identidades sin asignación
Una vista específica para:
- Identidades activas sin propietario.
- Credenciales próximas a caducar sin responsable.
- Identidades deshabilitadas aún relacionadas con proyectos.
- Asignaciones que apuntan a repositorios eliminados.
Incluso una tabla con filtros puede aportar suficiente valor para una primera versión.
API mínima
Una API pequeña podría ofrecer estos endpoints:
GET /api/identitiesGET /api/identities/:principalKeyPOST /api/identities/refresh
GET /api/scopesGET /api/assignmentsPOST /api/assignmentsPUT /api/assignments/:idDELETE /api/assignments/:idFiltros útiles:
GET /api/identities?status=expiringGET /api/identities?kind=managedIdentityGET /api/identities?assigned=falseGET /api/assignments?scopeKey=project:paymentsGET /api/assignments?principalKey=azure:tenant:object-idNo hace falta exponer todos los datos originales de Microsoft Graph. La API debería devolver únicamente los campos que necesita la interfaz.
Graph y ARM responden preguntas diferentes
Es fácil confundir Microsoft Graph con Azure Resource Manager porque ambos pueden utilizar credenciales obtenidas mediante @azure/identity.
Sin embargo, responden preguntas distintas.
| Pregunta | API recomendada |
|---|---|
| ¿Qué Service Principals existen en el tenant? | Microsoft Graph |
| ¿Cuándo caduca una credencial? | Microsoft Graph |
| ¿Qué App Registrations existen? | Microsoft Graph |
| ¿Qué suscripciones puede consultar una identidad? | Azure Management API |
| ¿Qué rol RBAC tiene una identidad sobre un recurso? | ARM Authorization |
| ¿Qué recursos existen en una suscripción? | Azure Resource Manager |
El inventario de Service Principals vive principalmente en Microsoft Entra y Microsoft Graph.
Las asignaciones RBAC viven en ARM.
El contexto de negocio —proyectos, repositorios, productos y equipos— vive en tu propia capa de gobernanza.
Son fuentes complementarias.
Ampliación: consultar asignaciones RBAC
Una evolución natural del sistema es consultar qué roles tiene cada Service Principal sobre recursos de Azure.
Esto permite mostrar información como:
Proyecto: Payments PlatformRepositorio: company/payments-apiRol Azure: ContributorÁmbito: /subscriptions/.../resourceGroups/payments-productionSin embargo, esta consulta aumenta la complejidad:
- Debes recorrer suscripciones.
- Consultar asignaciones de roles.
- Resolver definiciones de rol.
- Normalizar scopes.
- Gestionar permisos adicionales.
- Controlar el volumen de consultas.
Por eso puede dejarse fuera de la primera versión.
El sistema ya aporta valor relacionando identidades con proyectos y repositorios, incluso sin inventariar todo Azure RBAC.
Configuración mínima
Variables de entorno:
AZURE_TENANT_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxxAZURE_CLIENT_ID=yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyyAZURE_CLIENT_SECRET=********************************Permiso de Microsoft Graph:
| Permiso | Tipo | Finalidad |
|---|---|---|
Application.Read.All | Aplicación | Leer aplicaciones y Service Principals |
El permiso requiere consentimiento de administrador.
La identidad lectora debería utilizar el principio de mínimo privilegio y no compartir credenciales con otros procesos si puede evitarse.
En producción, guarda el secreto en un gestor adecuado, como Azure Key Vault, y evita incluirlo en archivos .env distribuidos con la aplicación.
Seguridad del propio sistema
El inventario contiene información sensible, aunque no almacene el valor de los secretos.
Puede revelar:
- Nombres de servicios internos.
- Client IDs.
- Fechas de caducidad.
- Relaciones entre proyectos e infraestructura.
- Repositorios de producción.
- Equipos responsables.
- Permisos y entornos.
Por eso conviene aplicar algunas medidas básicas:
- Restringir el acceso a usuarios autorizados.
- Separar lectura y edición de asignaciones.
- Registrar cambios.
- No almacenar valores de secretos.
- Evitar mostrar información innecesaria.
- Proteger los endpoints de refresco.
- Aplicar rate limiting cuando corresponda.
- Revisar quién puede exportar el inventario.
El sistema necesita conocer los metadatos de las credenciales, pero nunca debería recuperar ni guardar sus valores.
Microsoft Graph tampoco devuelve el valor de un secreto existente.
Cómo introducirlo en el trabajo diario
Un inventario solo es útil si se mantiene.
Estas prácticas ayudan a integrarlo en el proceso operativo.
Registrar la identidad al crearla
Cuando se crea un Service Principal nuevo, la tarea no debería considerarse terminada hasta que se indique:
- Proyecto.
- Repositorio.
- Entorno.
- Responsable.
- Justificación.
- Mecanismo de autenticación.
- Fecha de revisión.
Revisar identidades huérfanas
Programa una revisión periódica de identidades activas sin asignación.
Cada una debería terminar en una de estas situaciones:
- Se asigna a un responsable.
- Se documenta como aplicación externa.
- Se deshabilita.
- Se elimina después de validar que no se utiliza.
Definir una ventana de renovación
Treinta días suele ser una ventana razonable para empezar.
Algunas organizaciones necesitarán 60 o 90 días debido a sus procesos de cambio.
Lo importante es que la ventana sea visible y compartida.
Aplicar una convención de nombres
Un nombre como:
payments-api-production-deployerofrece más contexto que:
spn-003La convención no sustituye a las asignaciones, pero reduce mucho el tiempo de búsqueda.
Preferir OIDC en nuevos pipelines
Cada nuevo pipeline basado en Workload Identity Federation es un secreto menos que inventariar y rotar.
El sistema también puede registrar que una identidad utiliza federación:
authenticationMethod: workloadIdentityFederationAsí distingues entre identidades que requieren rotación manual y las que no.
Errores habituales
Consultar solo App Registrations
Deja fuera Enterprise Applications, Managed Identities y aplicaciones de terceros.
Consulta siempre /applications y /servicePrincipals.
Tratar App Registration y Service Principal como objetos independientes
Puede generar duplicados.
Relaciónalos mediante appId y conserva los object IDs de ambos.
Considerar que Managed Identity no necesita inventario
No necesita secretos rotables, pero sigue siendo una identidad con permisos.
Debe aparecer en el catálogo.
Usar el nombre como identificador
Los nombres pueden cambiar y no tienen por qué ser únicos.
Utiliza el object ID para la clave interna y conserva el appId como identificador de búsqueda.
Confiar únicamente en los propietarios de Entra
Un propietario administrativo no siempre es el responsable operativo del servicio.
Registra explícitamente proyecto, repositorio y equipo.
Guardar contexto de negocio directamente en Entra
Es posible utilizar atributos de extensión, pero puede mezclar responsabilidades y complicar el modelo.
Mantener el inventario técnico separado de las asignaciones operativas suele ser más flexible.
No refrescar el inventario después de una rotación
El sistema puede seguir mostrando una credencial antigua hasta que expire la caché.
Incluye un refresco manual o un proceso frecuente.
Crear varios inventarios independientes
Un inventario para seguridad, otro para plataforma y otro en una hoja de cálculo terminarán divergiendo.
Mantén una única fuente técnica y permite diferentes vistas sobre los mismos datos.
Automatizar eliminaciones demasiado pronto
Una identidad sin asignación no siempre es una identidad inútil.
Empieza generando alertas y revisiones humanas antes de automatizar acciones destructivas.
Una hoja de ruta razonable
No hace falta implementar todo desde el principio.
Primera versión
- Consultar
/applications. - Consultar
/servicePrincipals. - Unificar resultados.
- Detectar secretos caducados o próximos a caducar.
- Guardar el inventario.
- Mostrar una tabla.
- Permitir relacionar una identidad con un proyecto o repositorio.
Segunda versión
- Añadir equipos y entornos.
- Identificar identidades huérfanas.
- Incorporar auditoría.
- Enviar alertas.
- Añadir búsqueda por client ID.
- Integrar repositorios de GitHub o Azure DevOps.
Tercera versión
- Consultar Azure RBAC.
- Analizar logs de inicio de sesión.
- Detectar identidades inactivas.
- Crear flujos de revisión.
- Generar tickets automáticamente.
- Recomendar migraciones a Workload Identity Federation.
- Incorporar inventario de AWS o Google Cloud bajo el mismo modelo.
El objetivo no es construir desde el primer día una plataforma completa de Cloud Security Posture Management.
El objetivo es resolver una pregunta concreta:
Cuando esta identidad tenga un problema, ¿sabremos inmediatamente qué depende de ella y quién debe actuar?
Conclusión
Microsoft Entra ya permite descubrir las identidades de un tenant. El verdadero reto es añadir el contexto que falta alrededor de ellas.
Un sistema pequeño puede aportar mucho valor si combina dos capas:
- Inventario técnico, para conocer qué identidades existen, si están activas y cuándo caducan sus credenciales.
- Contexto operativo, para relacionar cada identidad con proyectos, repositorios, equipos y entornos.
Con esa base puedes detectar secretos próximos a caducar, localizar Service Principals huérfanos y reducir el tiempo necesario para responder a un incidente.
Siempre que sea posible, utiliza Managed Identity o Workload Identity Federation para reducir el número de secretos. Pero mientras existan Service Principals tradicionales, merece la pena mantener un inventario claro y una forma sencilla de relacionarlos con los servicios que dependen de ellos.
Porque el problema no es descubrir que una identidad existe.
El problema es saber qué dejará de funcionar cuando esa identidad falle.
Esta misma propuesta la he implantado en una de las soluciones que estoy desarrollando Goliat - Dashboard
Si quereis ver una demo podeis visitar el sitio web del proyecto
Referencias
- Application and service principal objects in Microsoft Entra ID
- Microsoft Graph: servicePrincipal resource type
- Microsoft Graph: application resource type
- ClientSecretCredential en el SDK de Azure para JavaScript
- Managed identities for Azure resources
- Workload identity federation


