Skip to content
← Tous les articles
· Delta1 Labs Licensing.NETGuide

Activer l'application des licences sans verrouiller vos clients : le mode audit d'abord

Le déploiement le plus effrayant de tout projet de licences est celui qui fait passer l'application de off à on. Jusque-là, la porte n'a jamais dit non à personne ; dès qu'elle le fait, chaque clé mal provisionnée, chaque horloge décalée d'un jour, chaque client qui a activé derrière un proxy que vous n'aviez pas prévu devient un ticket de support — tous en même temps, en production. Ce billet montre comment éviter la bascule brutale grâce au mode audit : une porte qui évalue la licence exactement comme elle le fera en mode application, enregistre la décision qu'elle aurait prise, puis laisse l'appel passer quand même. Vous l'exécutez sur toute votre base installée, vous lisez la population qui aurait été bloquée, vous corrigez les données, et seulement alors vous passez à l'application — par cohorte, piloté par un mode que la licence signée transporte pour qu'aucun client ne puisse l'éteindre. Avec un enregistrement de décision, une énumération de mode d'application que le payload signe, un déploiement par étapes, et les métriques qui vous disent qu'il est sûr de bloquer.

Le déploiement le plus effrayant d”un projet de licences n”est pas celui qui ajoute la porte. C”est celui qui l”allume.

Ajouter une vérification de licence à une application .NET qui a déjà des utilisateurs est surtout mécanique : vous vérifiez un payload signé, vous lisez les claims, vous décidez. Vous pouvez tester chaque branche de cette logique. Ce que vous ne pouvez pas tester, jusqu”à la mise en ligne, c”est votre propre base installée — les clés que de vrais clients tiennent réellement, les horloges que leurs machines font réellement tourner, les chemins réseau que leurs activations empruntent réellement. Le jour où vous faites passer l”application de « journalise la décision » à « agis sur la décision », toute cette réalité arrive d”un coup, et chaque écart que vous n”aviez pas prévu devient un client verrouillé et un ticket de support dans les mêmes cinq minutes.

Le mode audit, c”est la façon de découpler ces deux événements. Vous expédiez la porte en train de calculer la décision exacte qu”elle finira par appliquer, mais sans agir sur aucun des refus — en les enregistrant à la place. Puis vous observez, vous corrigez, et vous basculez, une cohorte à la fois, depuis un mode que la licence elle-même transporte.

payload de licence signésub: cust_8831edition: proexp: 2027-10-12enforcementMode: auditévaluer (identique dans les deux modes)decision = Denymotif : SeatCapExceededmode auditenregistre le blocagel''appel se poursuitmode application (plus tard)le même Denyl''appel est bloquéune branche diffère : si un Deny arrête l''appel

Séparer la décision de l”action

La première erreur de conception est d”écrire la porte comme une fonction qui renvoie un booléen et lève une exception quand le booléen est faux. Cela couple deux choses qui doivent pouvoir être séparées : ce que dit la licence et ce que vous en faites. Le mode audit n”existe que si ces deux-là sont distincts.

Donc la porte produit une valeur, pas un effet de bord. Une décision est un résultat plus un motif plus assez de contexte pour l”expliquer dans un journal plus tard :

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 est délibérément un résultat de première classe, pas un Deny. C”est le cas « je n”ai pas pu décider » — le serveur était injoignable, la licence en cache n”avait pas été rafraîchie, l”horloge n”était pas fiable. Le fondre dans Deny est la raison la plus courante pour laquelle un déploiement de licences verrouille des clients qui n”ont jamais été réellement sans licence, et le garder séparé est ce qui rend la métrique d”audit utile par la suite.

L”évaluation qui produit la décision est la vraie logique d”application, et elle est identique dans les deux modes :

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();
}

Rien ici ne sait si vous comptez agir sur un Deny. Cette connaissance vit un niveau plus haut, dans le mode.

Le mode appartient à la licence, pas à la configuration

Le mode audit n”est sûr que si le client ne peut pas l”atteindre. Si vous lisez le mode depuis appsettings.json, une variable d”environnement ou n”importe quel fichier non signé à côté du binaire, vous n”avez pas construit un outil de déploiement — vous avez construit un interrupteur d”arrêt, et la première personne qui le trouve n”est jamais bloquée.

Donc le mode est un claim. Il voyage à l”intérieur du payload signé aux côtés de l”expiration et de l”édition, ce qui signifie que le client le lit à partir d”octets dont il vérifie déjà la signature. Faire passer enforce à audit casse la signature exactement comme éditer l”expiration :

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 }

Le sens de la valeur par défaut compte. Un payload plus ancien antérieur au claim enforcementMode se désérialise avec le champ absent, et la valeur par défaut doit être Enforce. Si vous la mettez dans l”autre sens, chaque clé que vous avez jamais émise avant d”ajouter le claim devient silencieusement inapplicable — l”exact opposé de ce que vous voulez. Échouer de façon sûre signifie prendre par défaut la valeur stricte.

Le mode en main, la couche supérieure de la porte est la seule branche sur laquelle repose toute la conception :

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
}

Lisez attentivement cette dernière ligne : en mode audit, un Deny sûr est enregistré puis la méthode renvoie true, donc l”appel se poursuit. En mode application, le même Deny renvoie false. L”évaluation au-dessus n”a pas changé. C”est la propriété que vous voulez — que les comportements audit et application soient manifestement la même décision, ne différant que par le fait d”agir ou non dessus — parce que c”est ce qui vous permet d”avoir confiance que la population mesurée en audit est exactement la population que vous bloquerez en application.

Ce que le collecteur d”audit enregistre

L”enregistrement est le but même de l”exercice, donc il doit porter assez pour agir sans rien porter que vous ne devriez pas collecter. Vous voulez, par événement aurait-été-bloqué : le sujet auquel la licence a été émise, le motif, la fonctionnalité, le mode en vigueur, et un horodatage. Vous ne voulez pas les octets bruts de la licence, de données personnelles, ni rien qui identifie une personne plutôt qu”une licence.

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
}

Pour un déploiement auto-hébergé, cela est regroupé vers le service émetteur que vous appelez déjà pour l”activation ; pour le produit SaaS, c”est le même canal. Dans les deux cas la forme est la même, et l”agrégat que vous en construisez est une table que vous lirez chaque matin pendant le déploiement : motif, nombre de sujets distincts, tendance sur les derniers cycles de rafraîchissement. SeatCapExceeded qui grimpe est une conversation commerciale ; Expired qui grimpe est un problème d”opérations de renouvellement ; FeatureNotLicensed sur une fonctionnalité que vous pensiez que tout le monde avait est un bug de provisionnement que vous pouvez corriger avant qu”il ne refuse jamais qui que ce soit.

Basculez par cohorte, en émettant, pas en déployant

Comme le mode est un claim, faire passer un client d”audit à application est un acte d”émission, pas de déploiement. Vous n”expédiez pas une nouvelle build ; vous émettez sa prochaine licence (ou vous réémettez l”actuelle) avec enforcementMode: enforce, et le client la récupère à son prochain rafraîchissement. Cela vous donne un bouton de déploiement avec une résolution par sujet et sans coordination côté client :

// Issuing side: decide the mode as licenses are minted or refreshed.
EnforcementMode ModeFor(string sub) =>
    _rollout.IsEnforced(sub) ? EnforcementMode.Enforce : EnforcementMode.Audit;

Le déploiement par étapes se lit alors directement dans les données d”audit. Commencez par la cohorte dont l”enregistrement d”audit est le plus propre — locataires internes, puis les clients avec zéro événement aurait-été-bloqué sur un cycle de rafraîchissement complet — car pour ceux-là l”application ne change rien. Étendez vers l”extérieur à mesure que chaque motif de la table d”audit retombe à un plancher que vous pouvez attribuer. Les clients qui génèrent encore des refus restent en audit jusqu”à ce que vous ayez corrigé les données ou eu la conversation ; ils ne sont jamais surpris, car l”application pour eux n”a tout simplement pas encore été émise.

sujets aurait-été-bloqué / jourhaut0cycle 1cycle 2cycle 3plancher : chaque refus restant attribuable← faites basculer la cohorte propre ici, pas avant

Indeterminate n”est pas un refus, et audit n”est pas échouer en ouvert

Les deux erreurs qui défont tout cela sont la même erreur habillée différemment.

La première est de fondre Indeterminate dans Deny. Si un délai de serveur ou un cache vide produit un Deny, votre table d”audit se remplit d”événements qui n”ont rien à voir avec les licences, le taux aurait-été-bloqué ne se stabilise jamais car il suit votre réseau au lieu de vos licences, et quand vous passez enfin à l”application vous verrouillez tous ceux qui se trouvaient hors ligne. Gardez Indeterminate séparé et faites-le passer par la politique de grâce ou d”échec en ouvert que vous avez déjà — la métrique d”audit ne doit compter que les refus dont vous êtes sûr, car ce sont les seuls sur lesquels l”application agira.

La seconde est de confondre le mode audit avec l”échec en ouvert. L”échec en ouvert répond à « que faisons-nous quand nous ne pouvons pas décider » ; l”audit répond à « que faisons-nous d”une décision que nous avons prise mais sur laquelle nous choisissons de ne pas agir encore ». Une porte peut être en mode audit et échouer tout de même en fermé sur les cas indéterminés — les deux interrupteurs sont indépendants, et les confondre signifie que vous ne pouvez ni raisonner sur votre déploiement ni expliquer, après coup, pourquoi un appel donné a été autorisé.

Réussissez ces deux-là et le déploiement final — celui qui vous faisait peur — cesse d”être un déploiement. C”est une ligne dans le service émetteur qui émet la prochaine licence avec enforcementMode: enforce pour une cohorte dont vous avez déjà observé le taux aurait-été-bloqué retomber à un plancher que vous comprenez. Le code de la porte ne change pas. Vous savez déjà, au sujet près, qui cela affectera, car vous avez passé deux cycles de rafraîchissement à l”observer n”affecter personne.

Essayez Nebula.NET

Renforcez votre code .NET en quelques minutes — commencez avec l'édition gratuite.