Activar la aplicación de licencias sin dejar fuera a tus clientes: primero el modo auditoría
El despliegue más temible de cualquier proyecto de licenciamiento es el que pasa la aplicación de off a on. Hasta ese momento la puerta nunca le ha dicho que no a nadie; en cuanto lo hace, cada clave mal aprovisionada, cada reloj que va un día desfasado, cada cliente que activó tras un proxy que no previste se convierte en un ticket de soporte, todos a la vez, en producción. Esta entrada muestra cómo evitar el corte de golpe con el modo auditoría: una puerta que evalúa la licencia exactamente como lo hará en modo aplicación, registra la decisión que habría tomado y luego deja pasar la llamada de todos modos. La ejecutas sobre toda tu base instalada, lees la población que se habría bloqueado, corriges los datos y solo entonces pasas a aplicar, por cohortes, guiado por un modo que la licencia firmada transporta para que ningún cliente pueda apagarlo. Con un registro de decisión, un enum de modo de aplicación que el payload firma, un despliegue escalonado y las métricas que te dicen que es seguro bloquear.
El despliegue más temible de un proyecto de licenciamiento no es el que añade la puerta. Es el que la enciende.
Añadir una comprobación de licencia a una app .NET que ya tiene usuarios es sobre todo mecánico: verificas un payload firmado, lees los claims y decides. Puedes probar cada rama de esa lógica. Lo que no puedes probar, hasta que está en vivo, es tu propia base instalada: las claves que los clientes reales tienen de verdad en la mano, los relojes que sus máquinas ejecutan de verdad, las rutas de red que sus activaciones toman de verdad. El día que pasas la aplicación de “registra la decisión” a “actúa sobre la decisión”, toda esa realidad llega de golpe, y cada discrepancia que no previste se convierte en un cliente bloqueado y un ticket de soporte en los mismos cinco minutos.
El modo auditoría es cómo desacoplas esos dos eventos. Envías la puerta calculando la decisión exacta que acabará aplicando, pero sin actuar sobre ninguno de los rechazos, registrándolos en su lugar. Luego observas, corriges y cambias, una cohorte cada vez, desde un modo que la propia licencia transporta.
Separa la decisión de la acción
El primer error de diseño es escribir la puerta como una función que devuelve un booleano y lanza una excepción cuando el booleano es falso. Eso acopla dos cosas que deben poder separarse: lo que dice la licencia y lo que haces al respecto. El modo auditoría solo existe si eso está diferenciado.
Así que la puerta produce un valor, no un efecto secundario. Una decisión es un resultado más un motivo más el contexto suficiente para explicarla en un log después:
public enum LicenseOutcome { Allow, Deny, Indeterminate }
public sealed record LicenseDecision
{
public required LicenseOutcome Outcome { get; init; }
public string? Reason { get; init; } // "SeatCapExceeded", "Expired", "FeatureNotLicensed"
public string? Feature { get; init; } // what was being gated
public DateTimeOffset EvaluatedAt { get; init; }
public static LicenseDecision Allow() =>
new() { Outcome = LicenseOutcome.Allow, EvaluatedAt = DateTimeOffset.UtcNow };
public static LicenseDecision Deny(string reason, string? feature = null) =>
new() { Outcome = LicenseOutcome.Deny, Reason = reason, Feature = feature,
EvaluatedAt = DateTimeOffset.UtcNow };
public static LicenseDecision Indeterminate(string reason) =>
new() { Outcome = LicenseOutcome.Indeterminate, Reason = reason,
EvaluatedAt = DateTimeOffset.UtcNow };
}
Indeterminate es deliberadamente un resultado de primera clase, no un Deny. Es el caso de “no he podido decidir”: el servidor estaba inalcanzable, la licencia cacheada no se había refrescado, el reloj no era de fiar. Colapsarlo en Deny es la razón más común de que un despliegue de licenciamiento deje fuera a clientes que nunca estuvieron realmente sin licencia, y mantenerlo separado es lo que hace que la métrica de auditoría tenga sentido después.
La evaluación que produce la decisión es la lógica de aplicación real, y es idéntica en ambos modos:
public LicenseDecision Evaluate(string feature, int seatsInUse)
{
if (!_license.IsPresent)
return LicenseDecision.Indeterminate("NoLicenseCached");
if (_license.Expiry < _clock.TrustedUtcNow())
return LicenseDecision.Deny("Expired", feature);
if (!_license.Features.Contains(feature))
return LicenseDecision.Deny("FeatureNotLicensed", feature);
if (_license.SeatCap is int cap && seatsInUse > cap)
return LicenseDecision.Deny("SeatCapExceeded", feature);
return LicenseDecision.Allow();
}
Nada aquí sabe si piensas actuar sobre un Deny. Ese conocimiento vive un nivel más arriba, en el modo.
El modo pertenece a la licencia, no a la configuración
El modo auditoría solo es seguro si el cliente no puede alcanzarlo. Si lees el modo de appsettings.json, de una variable de entorno o de cualquier fichero sin firmar junto al binario, no has construido una herramienta de despliegue: has construido un interruptor de apagado, y la primera persona que lo encuentre no será bloqueada jamás.
Así que el modo es un claim. Viaja dentro del payload firmado junto a la caducidad y la edición, lo que significa que el cliente lo lee de unos bytes cuya firma ya verifica. Pasar enforce a audit rompe la firma igual que editar la caducidad:
public sealed record LicensePayload
{
public required string Sub { get; init; }
public required string Edition { get; init; }
public required DateTimeOffset Exp { get; init; }
public int? SeatCap { get; init; }
public IReadOnlyList<string> Features { get; init; } = [];
// Rollout control, signed like everything else. Default to enforce:
// a payload minted before this claim existed must be treated as enforcing,
// never as audit, or an old key becomes an accidental bypass.
public EnforcementMode EnforcementMode { get; init; } = EnforcementMode.Enforce;
}
public enum EnforcementMode { Enforce, Audit }
La dirección del valor por defecto importa. Un payload más antiguo anterior al claim enforcementMode se deserializa con el campo ausente, y el valor por defecto debe ser Enforce. Si eliges el contrario, cada clave que hayas emitido jamás antes de añadir el claim se vuelve en silencio inaplicable: lo contrario de lo que quieres. Fallar seguro significa tener por defecto el valor estricto.
Con el modo en la mano, la capa superior de la puerta es la única rama de la que depende todo el diseño:
public bool Gate(string feature, int seatsInUse)
{
var decision = Evaluate(feature, seatsInUse);
// Indeterminate is a separate policy decision (your grace / fail-open rule),
// handled before mode so the audit metric only ever counts confident denies.
if (decision.Outcome == LicenseOutcome.Indeterminate)
return _gracePolicy.AllowOnIndeterminate(decision);
if (decision.Outcome == LicenseOutcome.Allow)
return true;
// decision.Outcome == Deny
_auditSink.Record(decision, _license.Sub, _license.EnforcementMode);
return _license.EnforcementMode == EnforcementMode.Audit; // audit: proceed anyway
}
Lee con atención esa última línea: en modo auditoría un Deny seguro se registra y luego el método devuelve true, así que la llamada continúa. En modo aplicación el mismo Deny devuelve false. La evaluación de arriba no cambió. Esa es la propiedad que quieres —que los comportamientos de auditoría y aplicación sean demostrablemente la misma decisión, diferenciándose solo en si actúas sobre ella— porque es lo que te deja confiar en que la población que mediste en auditoría es exactamente la población que bloquearás en aplicación.
Qué registra el sumidero de auditoría
El registro es el objetivo mismo del ejercicio, así que tiene que llevar lo suficiente para actuar sin llevar nada que no debieras recoger. Quieres, por cada evento de se-habría-bloqueado: el sujeto al que se emitió la licencia, el motivo, la funcionalidad, el modo en vigor y una marca de tiempo. No quieres los bytes crudos de la licencia, datos personales ni nada que identifique a una persona en lugar de a una licencia.
public sealed record AuditEvent
{
public required string Sub { get; init; } // which license, not which person
public required string Reason { get; init; } // SeatCapExceeded, Expired, ...
public string? Feature { get; init; }
public required EnforcementMode Mode { get; init; }
public required DateTimeOffset At { get; init; }
public required string AppVersion { get; init; } // so you can see which build reports
}
Para un despliegue autoalojado esto se agrupa hacia el servicio emisor al que ya llamas para la activación; para el producto SaaS es el mismo canal. En cualquier caso la forma es la misma, y el agregado que construyes a partir de ello es una tabla que leerás cada mañana durante el despliegue: motivo, recuento de sujetos distintos, tendencia a lo largo de los últimos ciclos de refresco. SeatCapExceeded subiendo es una conversación de ventas; Expired subiendo es un problema de operaciones de renovación; FeatureNotLicensed en una funcionalidad que creías que todos tenían es un fallo de aprovisionamiento que puedes arreglar antes de que llegue a rechazar a nadie.
Cambia por cohorte, acuñando, no desplegando
Como el modo es un claim, pasar a un cliente de auditoría a aplicación es un acto de emisión, no de despliegue. No envías una build nueva; acuñas su próxima licencia (o reemites la actual) con enforcementMode: enforce, y el cliente la recoge en su próximo refresco. Eso te da una perilla de despliegue con resolución por sujeto y sin coordinación en el lado del cliente:
// Issuing side: decide the mode as licenses are minted or refreshed.
EnforcementMode ModeFor(string sub) =>
_rollout.IsEnforced(sub) ? EnforcementMode.Enforce : EnforcementMode.Audit;
El despliegue escalonado se lee entonces directamente de los datos de auditoría. Empieza por la cohorte cuyo registro de auditoría esté más limpio —inquilinos internos, luego los clientes con cero eventos de se-habría-bloqueado a lo largo de un ciclo de refresco completo—, porque para esos la aplicación no cambia nada. Expande hacia fuera a medida que cada motivo de la tabla de auditoría cae a un suelo que puedes atribuir. Los clientes que aún generan rechazos se quedan en auditoría hasta que hayas corregido los datos o tenido la conversación; nunca se llevan una sorpresa, porque la aplicación para ellos simplemente aún no se ha acuñado.
Indeterminate no es un deny, y auditoría no es fallar en abierto
Los dos errores que deshacen todo esto son el mismo error con distinta ropa.
El primero es fundir Indeterminate en Deny. Si un timeout de servidor o un caché vacío produce un Deny, tu tabla de auditoría se llena de eventos que nada tienen que ver con el licenciamiento, la tasa de se-habría-bloqueado nunca se estabiliza porque está siguiendo tu red en lugar de tus licencias, y cuando por fin pasas a aplicación dejas fuera a todo el que estuviera sin conexión. Mantén Indeterminate separado y hazlo pasar por la política de gracia o fallo en abierto que ya tengas: la métrica de auditoría debe contar solo los rechazos de los que estás seguro, porque son los únicos sobre los que actuará la aplicación.
El segundo es confundir el modo auditoría con fallar en abierto. Fallar en abierto responde a “qué hacemos cuando no podemos decidir”; la auditoría responde a “qué hacemos con una decisión que hemos tomado pero sobre la que elegimos no actuar aún”. Una puerta puede estar en modo auditoría y aun así fallar en cerrado ante casos indeterminados: los dos interruptores son independientes, y confundirlos significa que no puedes ni razonar sobre tu despliegue ni explicar, a posteriori, por qué se permitió una llamada concreta.
Acierta en esos dos y el despliegue final —el que te daba miedo— deja de ser un despliegue siquiera. Es una línea en el servicio emisor que acuña la próxima licencia con enforcementMode: enforce para una cohorte cuya tasa de se-habría-bloqueado ya has observado caer a un suelo que entiendes. El código de la puerta no cambia. Ya sabes, al sujeto, a quién afectará, porque pasaste dos ciclos de refresco observando cómo no afectaba a nadie.
Prueba Nebula.NET
Endurece tu código .NET en minutos — empieza con la edición gratuita.