Cómo inventariar Service Principals en Azure y ligarlos a proyectos y repositorios

  • Imagen de redactor Daniel J. Saldaña
  • 20 de julio de 2026
Cómo inventariar Service Principals en Azure y ligarlos a proyectos y repositorios

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:

  1. Descubrir las identidades existentes en Microsoft Entra.
  2. Revisar si están activas.
  3. Detectar secretos y certificados próximos a caducar.
  4. Relacionar cada identidad con un proyecto, repositorio, equipo o entorno.
  5. 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».

ConceptoQué representaDónde consultarlo
App RegistrationLa definición global de una aplicación: permisos, URI, credenciales y configuración de autenticaciónGET /applications en Microsoft Graph
Enterprise ApplicationLa instancia de una aplicación dentro de un tenantGET /servicePrincipals
Service PrincipalLa identidad no humana utilizada para autenticarse y obtener tokensEs 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 --> Consumers

La 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:

  1. Inventario técnico, obtenido desde Microsoft Graph.
  2. 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.

EscenarioOpción recomendadaMotivo
App Service o Azure Function accede a Storage o Key VaultManaged IdentityNo requiere secretos gestionados por el equipo
Pod de AKS accede a recursos de AzureWorkload Identity o identidad asignada por el usuarioIntegración nativa con la identidad del workload
GitHub Actions despliega en AzureWorkload Identity Federation mediante OIDCEvita almacenar un client secret en GitHub
Runner local o desplegado en otra nubeService Principal con certificado o secretoNo existe un recurso de Azure que pueda emitir la identidad
Integración con un SaaS externoEnterprise ApplicationEl proveedor controla la aplicación
Script legacy con variables de entornoService Principal con secretoPuede 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 --> Alerts

La 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.All

Debe 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 /applications

Eso 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 /applications
GET /servicePrincipals

Puedes 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:

  1. Indexar los Service Principals por appId.
  2. Recorrer las App Registrations.
  3. Relacionar cada aplicación con su Service Principal.
  4. Marcar los Service Principals ya utilizados.
  5. 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:

  • applicationObjectId
  • servicePrincipalObjectId
  • appId

Tipos de identidad que aparecerán en el inventario

Puedes normalizar los resultados en tres categorías principales:

Tipo normalizadoOrigen habitual¿Tiene credenciales rotables?
applicationRegistrationAplicación creada en tu tenantHabitualmente sí
enterpriseApplicationSaaS, aplicación de catálogo o app multi-tenantDepende del publisher
managedIdentityVM, App Service, Function, AKS u otro recursoNo 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:

managed

Una Enterprise Application de terceros sin credenciales visibles puede marcarse como:

notApplicable

Y una App Registration propia sin secretos ni certificados puede mostrarse como:

noCredentials

Paso 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.

EstadoSignificado
expiredAl menos una credencial ya ha caducado
expiringAlguna credencial caduca dentro de la ventana configurada
validLas credenciales están vigentes
scheduledExiste una credencial cuya fecha de inicio aún no ha llegado
noCredentialsLa aplicación no tiene secretos ni certificados
managedEs una Managed Identity
notApplicableNo corresponde evaluar credenciales
unknownNo 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:

  1. Identidades deshabilitadas.
  2. Credenciales caducadas.
  3. Credenciales próximas a caducar.
  4. Identidades sin credenciales.
  5. Identidades sanas.

Una alerta técnica no es suficiente

Imagina que el sistema muestra lo siguiente:

Client ID: a1b2c3d4-...
Estado: expiring
Caduca en: 15 días

La 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 ámbitoEjemploCuándo utilizarlo
projectPayments PlatformLa identidad es compartida por varios repositorios de un producto
repositorycompany/payments-apiLa identidad pertenece a un pipeline concreto
organizationcompanyLa identidad se utiliza a nivel de organización
teamPlatform PaymentsUn equipo es responsable de su operación
environmentproductionLa 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-spn
Client ID: a1b2c3d4-...
Estado: expiring
Caduca en: 12 días

Sin contexto operativo, alguien debe investigar dónde se utiliza.

Con las asignaciones, el sistema puede mostrar:

Proyecto: Payments Platform
Repositorio: company/payments-api
Entorno: production
Equipo: Platform Payments
Uso: workflow deploy-production.yml
Permiso: despliegue en Azure

El recorrido del incidente sería:

  1. El proceso de inventario detecta una credencial próxima a caducar.
  2. La aplicación busca las asignaciones del Service Principal.
  3. El equipo de plataforma identifica inmediatamente el repositorio afectado.
  4. Se rota la credencial en Microsoft Entra.
  5. Se actualiza el secreto o la configuración del workflow.
  6. Se ejecuta una validación del despliegue.
  7. El inventario se actualiza.
  8. El estado cambia de expiring a valid.

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/identities
GET /api/identities/:principalKey
POST /api/identities/refresh
GET /api/scopes
GET /api/assignments
POST /api/assignments
PUT /api/assignments/:id
DELETE /api/assignments/:id

Filtros útiles:

GET /api/identities?status=expiring
GET /api/identities?kind=managedIdentity
GET /api/identities?assigned=false
GET /api/assignments?scopeKey=project:payments
GET /api/assignments?principalKey=azure:tenant:object-id

No 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.

PreguntaAPI 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 Platform
Repositorio: company/payments-api
Rol Azure: Contributor
Ámbito: /subscriptions/.../resourceGroups/payments-production

Sin 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:

Terminal window
AZURE_TENANT_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
AZURE_CLIENT_ID=yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy
AZURE_CLIENT_SECRET=********************************

Permiso de Microsoft Graph:

PermisoTipoFinalidad
Application.Read.AllAplicaciónLeer 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-deployer

ofrece más contexto que:

spn-003

La 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: workloadIdentityFederation

Así 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:

  1. Inventario técnico, para conocer qué identidades existen, si están activas y cuándo caducan sus credenciales.
  2. 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

¡Suscríbete y recibe actualizaciones sobre tecnología, diseño, productividad, programación y mucho más!
0
0