Lizenzdurchsetzung einschalten, ohne die Kunden auszusperren: erst der Audit-Modus
Das beängstigendste Deployment jedes Lizenzierungsprojekts ist das, welches die Durchsetzung von aus auf an schaltet. Bis dahin hat das Tor niemandem Nein gesagt; in dem Moment, in dem es das tut, wird jeder falsch bereitgestellte Schlüssel, jede um einen Tag abweichende Uhr, jeder Kunde, der hinter einem nicht vorhergesehenen Proxy aktiviert hat, zu einem Support-Ticket — alle auf einmal, in der Produktion. Dieser Beitrag zeigt, wie man den großen Umschaltmoment mit dem Audit-Modus vermeidet: ein Tor, das die Lizenz genau so auswertet, wie es im Durchsetzungsmodus tun wird, die Entscheidung protokolliert, die es getroffen hätte, und den Aufruf dann trotzdem durchlässt. Du führst es über deinen gesamten installierten Bestand aus, liest die Population, die blockiert worden wäre, korrigierst die Daten und schaltest erst dann auf Durchsetzung um — pro Kohorte, gesteuert von einem Modus, den die signierte Lizenz transportiert, damit kein Client ihn ausschalten kann. Mit einem Entscheidungsdatensatz, einem Durchsetzungsmodus-Enum, das die Nutzlast signiert, einem gestaffelten Rollout und den Metriken, die dir sagen, dass das Blockieren sicher ist.
Das beängstigendste Deployment in einem Lizenzierungsprojekt ist nicht das, welches das Tor hinzufügt. Es ist das, welches es einschaltet.
Eine Lizenzprüfung zu einer .NET-App hinzuzufügen, die bereits Nutzer hat, ist größtenteils mechanisch: Du prüfst eine signierte Nutzlast, liest die Claims und entscheidest. Du kannst jeden Zweig dieser Logik testen. Was du nicht testen kannst, bis es live ist, ist dein eigener installierter Bestand — die Schlüssel, die echte Kunden tatsächlich in der Hand halten, die Uhren, die ihre Maschinen tatsächlich laufen lassen, die Netzwerkpfade, die ihre Aktivierungen tatsächlich nehmen. An dem Tag, an dem du die Durchsetzung von „protokolliere die Entscheidung” auf „handle nach der Entscheidung” kippst, kommt all diese Realität auf einmal an, und jede Abweichung, die du nicht vorhergesehen hast, wird in denselben fünf Minuten zu einem ausgesperrten Kunden und einem Support-Ticket.
Der Audit-Modus ist, wie du diese beiden Ereignisse entkoppelst. Du lieferst das Tor aus, das genau die Entscheidung berechnet, die es schließlich durchsetzen wird, aber nach keiner der Verweigerungen handelt — es protokolliert sie stattdessen. Dann beobachtest, korrigierst und schaltest du um, eine Kohorte nach der anderen, von einem Modus aus, den die Lizenz selbst transportiert.
Trenne die Entscheidung von der Aktion
Der erste Designfehler ist, das Tor als Funktion zu schreiben, die einen Boolean zurückgibt und wirft, wenn der Boolean falsch ist. Das koppelt zwei Dinge, die trennbar sein müssen: was die Lizenz sagt und was du dagegen tust. Der Audit-Modus existiert nur, wenn diese getrennt sind.
Also produziert das Tor einen Wert, keinen Seiteneffekt. Eine Entscheidung ist ein Ergebnis plus ein Grund plus genug Kontext, um sie später in einem Protokoll zu erklären:
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 ist bewusst ein erstklassiges Ergebnis, kein Deny. Es ist der Fall „ich konnte nicht entscheiden” — der Server war nicht erreichbar, die gecachte Lizenz war nicht aktualisiert, der Uhr war nicht zu trauen. Es in Deny zusammenzufassen ist der häufigste Grund, warum ein Lizenzierungs-Rollout Kunden aussperrt, die nie wirklich unlizenziert waren, und es getrennt zu halten ist, was die Audit-Metrik später sinnvoll macht.
Die Auswertung, die die Entscheidung produziert, ist die echte Durchsetzungslogik, und sie ist in beiden Modi identisch:
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();
}
Nichts hier weiß, ob du nach einem Deny handeln willst. Dieses Wissen lebt eine Ebene höher, im Modus.
Der Modus gehört in die Lizenz, nicht in die Konfiguration
Der Audit-Modus ist nur sicher, wenn der Client ihn nicht erreichen kann. Wenn du den Modus aus appsettings.json, einer Umgebungsvariablen oder einer beliebigen unsignierten Datei neben der Binärdatei liest, hast du kein Rollout-Werkzeug gebaut — du hast einen Ausschalter gebaut, und die erste Person, die ihn findet, wird nie blockiert.
Also ist der Modus ein Claim. Er reist innerhalb der signierten Nutzlast neben Ablauf und Edition, was bedeutet, dass der Client ihn aus Bytes liest, deren Signatur er ohnehin prüft. enforce auf audit zu kippen bricht die Signatur genau so wie das Bearbeiten des Ablaufs:
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 }
Die Richtung des Standardwerts ist wichtig. Eine ältere Nutzlast, die vor dem enforcementMode-Claim liegt, wird mit fehlendem Feld deserialisiert, und der Standardwert muss Enforce sein. Wählst du die andere Richtung, wird jeder Schlüssel, den du je vor Einführung des Claims ausgestellt hast, stillschweigend nicht durchsetzbar — das Gegenteil von dem, was du willst. Sicher zu scheitern bedeutet, auf den strengen Wert vorzugeben.
Mit dem Modus in der Hand ist die oberste Schicht des Tors der eine Zweig, um den sich das ganze Design dreht:
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
}
Lies diese letzte Zeile sorgfältig: Im Audit-Modus wird ein sicheres Deny erfasst und die Methode gibt dann true zurück, sodass der Aufruf weiterläuft. Im Durchsetzungsmodus gibt dasselbe Deny false zurück. Die Auswertung darüber hat sich nicht geändert. Das ist die Eigenschaft, die du willst — dass Audit- und Durchsetzungsverhalten nachweislich dieselbe Entscheidung sind und sich nur darin unterscheiden, ob du danach handelst — denn genau das lässt dich darauf vertrauen, dass die im Audit gemessene Population genau die Population ist, die du in der Durchsetzung blockieren wirst.
Was die Audit-Senke erfasst
Der Datensatz ist der eigentliche Zweck der Übung, also muss er genug tragen, um zu handeln, ohne irgendetwas zu tragen, das du nicht erfassen solltest. Du willst, pro Wäre-blockiert-Ereignis: das Subjekt, dem die Lizenz ausgestellt wurde, den Grund, das Feature, den geltenden Modus und einen Zeitstempel. Du willst nicht die rohen Lizenz-Bytes, keine personenbezogenen Daten und nichts, das eine Person statt einer Lizenz identifiziert.
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
}
Bei einer selbstgehosteten Bereitstellung wird dies zum ausstellenden Dienst gebündelt, den du ohnehin für die Aktivierung aufrufst; beim SaaS-Produkt ist es derselbe Kanal. In beiden Fällen ist die Form gleich, und die Aggregation, die du daraus baust, ist eine Tabelle, die du während des Rollouts jeden Morgen liest: Grund, Anzahl eindeutiger Subjekte, Trend über die letzten Aktualisierungszyklen. Steigendes SeatCapExceeded ist ein Vertriebsgespräch; steigendes Expired ist ein Problem der Verlängerungs-Operationen; FeatureNotLicensed bei einem Feature, von dem du dachtest, alle hätten es, ist ein Bereitstellungsfehler, den du beheben kannst, bevor er je jemanden verweigert.
Schalte pro Kohorte um, durch Ausstellen, nicht durch Deployen
Da der Modus ein Claim ist, ist das Umschalten eines Clients von Audit auf Durchsetzung ein Akt des Ausstellens, nicht des Deployens. Du lieferst keine neue Build aus; du prägst seine nächste Lizenz (oder stellst die aktuelle neu aus) mit enforcementMode: enforce, und der Client nimmt sie bei seiner nächsten Aktualisierung auf. Das gibt dir einen Rollout-Knopf mit Auflösung pro Subjekt und ohne clientseitige Koordination:
// Issuing side: decide the mode as licenses are minted or refreshed.
EnforcementMode ModeFor(string sub) =>
_rollout.IsEnforced(sub) ? EnforcementMode.Enforce : EnforcementMode.Audit;
Der gestaffelte Rollout liest sich dann direkt aus den Audit-Daten. Beginne mit der Kohorte, deren Audit-Datensatz am saubersten ist — interne Mandanten, dann die Kunden mit null Wäre-blockiert-Ereignissen über einen vollen Aktualisierungszyklus — denn für die ändert die Durchsetzung nichts. Weite nach außen aus, während jeder Grund in der Audit-Tabelle auf ein Niveau fällt, das du zuordnen kannst. Die Kunden, die noch Verweigerungen erzeugen, bleiben im Audit, bis du die Daten korrigiert oder das Gespräch geführt hast; sie werden nie überrascht, denn die Durchsetzung ist für sie schlicht noch nicht geprägt worden.
Indeterminate ist kein Deny, und Audit ist nicht offenes Scheitern
Die beiden Fehler, die all das zunichtemachen, sind derselbe Fehler in anderer Kleidung.
Der erste ist, Indeterminate in Deny zusammenzufassen. Wenn ein Server-Timeout oder ein leerer Cache ein Deny produziert, füllt sich deine Audit-Tabelle mit Ereignissen, die nichts mit Lizenzierung zu tun haben, die Wäre-blockiert-Rate pendelt sich nie ein, weil sie dein Netzwerk statt deiner Lizenzen verfolgt, und wenn du endlich auf Durchsetzung umschaltest, sperrst du jeden aus, der zufällig offline war. Halte Indeterminate getrennt und leite es durch die Kulanz- oder Offen-Scheitern-Regel, die du bereits hast — die Audit-Metrik darf nur die Verweigerungen zählen, derer du sicher bist, denn das sind die einzigen, nach denen die Durchsetzung handeln wird.
Der zweite ist, den Audit-Modus mit offenem Scheitern zu verwechseln. Offenes Scheitern beantwortet „was tun wir, wenn wir nicht entscheiden können”; der Audit beantwortet „was tun wir mit einer Entscheidung, die wir getroffen haben, nach der wir aber noch nicht handeln wollen”. Ein Tor kann im Audit-Modus sein und bei unbestimmten Fällen trotzdem geschlossen scheitern — die beiden Schalter sind unabhängig, und sie zu verwechseln bedeutet, dass du weder über deinen Rollout nachdenken noch im Nachhinein erklären kannst, warum ein bestimmter Aufruf erlaubt wurde.
Mach diese beiden richtig, und das endgültige Deployment — das, vor dem du Angst hattest — ist gar kein Deployment mehr. Es ist eine Zeile im ausstellenden Dienst, die die nächste Lizenz mit enforcementMode: enforce prägt, für eine Kohorte, deren Wäre-blockiert-Rate du bereits auf ein Niveau fallen gesehen hast, das du verstehst. Der Code des Tors ändert sich nicht. Du weißt bereits, bis aufs Subjekt genau, wen es betreffen wird, denn du hast zwei Aktualisierungszyklen damit verbracht, zu beobachten, wie es niemanden betraf.
Nebula.NET testen
Härten Sie Ihren .NET-Code in wenigen Minuten — starten Sie mit der kostenlosen Edition.