Aller au contenu

dynors-entitlements — Service d'autorisation runtime

État d'implémentation (2026-06)

Slice dev-local codé (dynors-platform/entitlements) + client partagé (dynors-entitlements-client, fail-closed). Voir Auth · Entitlements · Sécurité — état d'implémentation.

dynors-entitlements est le moteur d'autorisation runtime de l'écosystème DYNORS. Il répond à une seule question : « ce sujet a-t-il le droit de faire ça maintenant ? » — indépendamment de la manière dont ce droit a été acquis (souscription, achat, onboarding, grant).

Il fait partie de dynors-platform, est routé via SLY sur /entitlements/**, et n'est appelé que par les autres apps DYNORS (jamais directement par un frontend).


1. Pourquoi ce service existe

L'écosystème DYNORS commercialise ses produits selon quatre modèles distincts qui ne peuvent pas être unifiés sous un seul système de facturation :

Modèle commercial Exemples DYNORS Source de vérité du droit
B2B SaaS récurrent SuperGest, Mediconnect (cabinets), TRACIUM, BOOKS, JARAAF Contrat dans dynors-billing
B2B onboardé sans facturation amont ELISA (e-commerçants, transporteurs partenaires), TRACIUM (chargeurs externes éventuels) Onboarding dans l'app produit elle-même
B2C achat à l'unité ou à la formule DAWALALE (packs permis B/A), Mediconnect (consultations à l'acte) Paiement validé → event de l'app produit
Grant administratif Comptes internes DYNORS, démos, support, free trial Back-office admin ou dynors-auth

Conséquence : faire de dynors-billing la source unique d'autorisation serait incorrect. ELISA n'a pas de souscription mensuelle, DAWALALE vend un pack à 25 000 FCFA à un apprenant qui n'est pas un tenant, un compte démo n'a pas de facture. Forcer ces cas dans le moule SaaS pollue le modèle de facturation et crée des dépendances critiques mal placées.

Le bon découpage :

  • dynors-billing reste maître du B2B récurrent (factures pro forma, renouvellements, dunning, prorata, gestion des sièges, suspension pour impayé).
  • dynors-entitlements est la projection runtime « qui peut accéder à quoi en ce moment », alimentée par plusieurs producteurs dont dynors-billing n'est qu'un cas particulier.
  • dynors-auth reste minimaliste — il authentifie, il n'autorise pas. Le JWT ne porte ni rôles ni produits.

2. Définition d'un entitlement

Un entitlement est un enregistrement signé qui dit : « le sujet S a, dans le scope C, les permissions P, jusqu'au moment T, parce que la source O l'a accordé ».

2.1 Schéma de données

TABLE entitlement
─────────────────────────────────────────────────────────────────
id                UUID            PK
subject_type      ENUM            'TENANT' | 'USER' | 'CUSTOMER' | 'COURIER'
subject_id        VARCHAR(64)     id du tenant, user, client final, livreur
scope             VARCHAR(128)    'app:supergest', 'course:permis-b',
                                  'feature:elisa-pro-dispatch', 'tier:gold'
permissions       JSONB           ["MANAGER","STOCK_WRITE"] ou
                                  {"max_deliveries_per_day": 1000}
granted_at        TIMESTAMP       date d'octroi
expires_at        TIMESTAMP       null = permanent jusqu'à révocation
source            VARCHAR(64)     'dynors-billing', 'elisa', 'dawalale',
                                  'admin-grant'
source_ref        VARCHAR(128)    'contract-1234', 'purchase-9876',
                                  'onboarding-abc'
status            ENUM            'ACTIVE' | 'SUSPENDED' | 'REVOKED' | 'EXPIRED'
metadata          JSONB           libre — propre à la source
created_at, updated_at, deleted_at
─────────────────────────────────────────────────────────────────
INDEX (subject_type, subject_id, scope, status) -- requête runtime
INDEX (source, source_ref)                       -- traçabilité

2.2 Types de sujets (subject_type)

Type Émis par Usage
TENANT dynors-billing, ELISA, admin Le tenant a-t-il accès à l'app ? au tier Pro ?
USER dynors-auth L'utilisateur (employé tenant) a-t-il un rôle dans une app ?
CUSTOMER DAWALALE, Mediconnect Le client final B2C a-t-il acheté ce pack / cette consultation ?
COURIER ELISA Le livreur est-il KYC validé et actif ?

3. Architecture événementielle

dynors-entitlements ne décide rien. Il consomme des events émis par les producteurs légitimes, projette l'état dans son store, et expose une API de lecture.

┌─────────────────────────────────────────────────────────────┐
│                      PRODUCTEURS D'EVENTS                    │
├─────────────────────────────────────────────────────────────┤
│                                                              │
│  dynors-billing  ──► tenant.contract.product.added           │
│                  ──► tenant.contract.product.removed         │
│                  ──► tenant.contract.suspended (impayé)      │
│                  ──► tenant.contract.seats.changed           │
│                                                              │
│  elisa-backend   ──► elisa.tenant.activated                  │
│                  ──► elisa.tenant.suspended                  │
│                  ──► elisa.courier.kyc.validated             │
│                  ──► elisa.courier.suspended                 │
│                                                              │
│  dawalale-backend──► dawalale.pack.purchased                 │
│                  ──► dawalale.pack.expired                   │
│                  ──► dawalale.formule.upgraded               │
│                                                              │
│  mediconnect-be  ──► mediconnect.subscription.activated      │
│                  ──► mediconnect.consultation.granted        │
│                                                              │
│  dynors-auth     ──► user.role.assigned                      │
│                  ──► user.role.revoked                       │
│                                                              │
│  admin-panel     ──► admin.entitlement.granted               │
│                  ──► admin.entitlement.revoked               │
│                                                              │
└──────────────────────────────┬──────────────────────────────┘
                               │  dynors-events (RabbitMQ/Kafka)
                               ▼
                ┌──────────────────────────────────┐
                │      dynors-entitlements         │
                │  ┌────────────────────────────┐  │
                │  │  Event consumer            │  │
                │  │  ▼                          │  │
                │  │  Projection (UPSERT)       │  │
                │  │  ▼                          │  │
                │  │  PostgreSQL + Redis cache  │  │
                │  └────────────────────────────┘  │
                │                                   │
                │  Expose : GET /entitlements/...   │
                └──────────────────┬────────────────┘
                                   │  via SLY /entitlements/**
       ┌───────────────────┬───────┼───────────────────┬─────────────┐
       ▼                   ▼       ▼                   ▼             ▼
  SuperGest-API      Mediconnect-API   ELISA-API   DAWALALE-API   …
  (consumer)         (consumer)        (consumer)  (consumer)

3.1 Format normalisé des events

Tous les events portent un payload commun (extension dynors-events v1.0.4) :

{
  "type": "dawalale.pack.purchased",
  "event_id": "evt-uuid",
  "occurred_at": "2026-05-07T14:32:11Z",
  "producer": "dawalale",
  "entitlement": {
    "subject_type": "CUSTOMER",
    "subject_id": "customer-uuid",
    "scope": "course:permis-b",
    "permissions": { "lessons": ["b1","b2","b3"], "max_attempts": 3 },
    "expires_at": "2026-08-07T14:32:11Z",
    "source": "dawalale",
    "source_ref": "purchase-9876"
  }
}

dynors-entitlements consomme et fait UPSERT (subject_type, subject_id, scope) → row avec gestion de l'idempotence par event_id.


4. API exposée

Tous les endpoints sont accessibles uniquement via SLY (/entitlements/**), avec SlyTransitFilter HMAC obligatoire — aucun frontend ne parle directement à dynors-entitlements.

4.1 GET /entitlements/check

La requête runtime principale. Réponse cachée 60 secondes côté appelant.

Request :

GET /entitlements/check?subject_type=USER&subject_id=user-uuid&scope=app:supergest

Response 200 :

{
  "allowed": true,
  "subject_id": "user-uuid",
  "scope": "app:supergest",
  "permissions": ["MANAGER","STOCK_WRITE"],
  "expires_at": "2026-12-31T23:59:59Z",
  "source": "dynors-billing",
  "source_ref": "contract-1234"
}

Response 200 (refus) :

{
  "allowed": false,
  "subject_id": "user-uuid",
  "scope": "app:supergest",
  "reason": "PRODUCT_NOT_SUBSCRIBED",
  "details": "Le tenant teranga-cereales n'a pas SuperGest dans son contrat actif"
}

Codes de refus normalisés :

reason Interprétation
PRODUCT_NOT_SUBSCRIBED Pas de contrat actif (B2B SaaS)
PURCHASE_NOT_FOUND Pas d'achat valide (B2C transactionnel)
PURCHASE_EXPIRED Achat expiré
TENANT_SUSPENDED Suspension administrative
CONTRACT_SUSPENDED Impayé / dunning
KYC_PENDING Onboarding pas terminé
ROLE_NOT_GRANTED Pas de rôle dans le scope demandé

4.2 GET /entitlements/me

Pratique pour un frontend qui veut connaître toutes les capacités d'un utilisateur en un seul appel (au login, par exemple).

Request : (authentifiée par cookie JWT)

GET /entitlements/me

Response :

{
  "subject_id": "user-uuid",
  "tenant": "teranga-cereales",
  "entitlements": [
    { "scope": "app:supergest", "permissions": ["MANAGER"], "expires_at": "2026-12-31T..." },
    { "scope": "app:tracium",   "permissions": ["VIEWER"],  "expires_at": "2026-12-31T..." }
  ]
}

4.3 GET /entitlements/by-subject

Pour les back-offices admin et la traçabilité.

GET /entitlements/by-subject?subject_type=TENANT&subject_id=teranga-cereales

4.4 POST /entitlements/grant (admin uniquement)

Pour un grant manuel (free trial commercial, démo, support exceptionnel). Émet un event interne admin.entitlement.granted qui passe par la même projection que les autres sources — la cohérence est préservée.


5. Cache et invalidation

Chaque app cliente cache les réponses entitlements/check 60 secondes localement (Redis ou Caffeine in-process selon l'app). Quand un producteur publie un event de révocation, dynors-entitlements republie un event entitlement.invalidated que les apps consomment pour purger leur cache immédiatement.

Latence maximale entre un changement de droit et l'effet dans toutes les apps : ~5 secondes en régime nominal (event publish → consumer ack → cache purge), 60 secondes en mode dégradé si le bus events est temporairement indisponible.


6. Producteurs par produit DYNORS

6.1 SuperGest, Mediconnect (cabinets), TRACIUM, BOOKS, JARAAF — B2B SaaS récurrent

Producteur : dynors-billing.

Le contrat tenant dans dynors-billing référence des produits, des sièges, une période. À chaque modification :

  • tenant.contract.product.added → entitlement (TENANT, tenant-id, app:<code>) créé
  • tenant.contract.product.removed → entitlement révoqué
  • tenant.contract.suspended (impayé après dunning N+30) → status SUSPENDED (lecture seule possible côté apps), réactivable au paiement
  • tenant.contract.cancelled → status REVOKED

dynors-billing reste maître de l'aspect contractuel (factures, renouvellements, dunning). Il publie simplement les conséquences sur les droits.

6.2 ELISA — B2B onboardé sans abonnement

Producteur : elisa-backend (l'app elle-même).

Il n'y a pas de contrat dans dynors-billing. Le tenant ELISA (e-commerçant, partenaire transport) signe un contrat-cadre offline (KYC, conditions, taux de commission), un admin DYNORS valide dans le back-office ELISA, et l'app publie :

  • elisa.tenant.activated avec tier: standard|pro|enterprise → entitlement (TENANT, tenant-id, app:elisa) + (TENANT, tenant-id, tier:elisa-pro) selon le contrat-cadre

Le revenu d'ELISA (commission par livraison, frais marketplace) est facturé a posteriori par FISCAL sur la base des events elisa.delivery.completed.billable — ça relève de FISCAL, pas de dynors-entitlements.

Producteur additionnel : elisa.courier.kyc.validated pour les livreurs (entitlement (COURIER, courier-id, role:active)).

6.3 DAWALALE — B2C achat de packs

Producteur : dawalale-backend (l'app elle-même).

Un apprenant achète un pack via DYNORS PAIEMENT. À la confirmation du paiement :

  • dawalale.pack.purchased → entitlement (CUSTOMER, customer-id, course:permis-b) avec expires_at selon la durée du pack et permissions listant les leçons unlockées

Pas de tenant impliqué (l'apprenant n'est pas employé d'une entreprise — il est client direct de DAWALALE). Le subject_type est CUSTOMER.

Si DAWALALE introduit un mode B2B (auto-écoles partenaires qui achètent en gros pour leurs élèves), une seconde dimension d'entitlement (TENANT, ecole-id, app:dawalale-pro) peut être ajoutée sans changer l'architecture.

6.4 Mediconnect — mix B2B + B2C

Producteurs : dynors-billing (cabinets en abonnement) + mediconnect-backend (consultations B2C).

  • Cabinets : tenant.contract.product.added → entitlement (TENANT, cabinet-id, app:mediconnect)
  • Patients : mediconnect.consultation.granted → entitlement (CUSTOMER, patient-id, scope:consultation-{doctor-id}) avec expires_at à 7 jours pour les questions de suivi

Les deux populations cohabitent sans conflit puisque les subject_type diffèrent.

6.5 ELISA tracking public, DAWALALE catalogue marketing — hors entitlements

Pour les flux publics sans session (un acheteur qui suit une livraison via lien SMS, un visiteur qui consulte le catalogue de packs DAWALALE), dynors-entitlements n'est pas appelé. L'app gère ces cas avec :

  • soit un endpoint public sans auth
  • soit un token JWT minimal de tracking : { aud: "tracking", resource_id: "delivery-xyz", exp: ... } signé directement par l'app

C'est intentionnel — créer des entitlements pour des accès anonymes serait du sur-design et polluerait la base.


7. JWT minimal — séparation stricte authentification/autorisation

Le JWT émis par dynors-auth ne porte plus de rôles ni de produits. Il devient :

{
  "iss": "dynors-auth",
  "sub": "user-uuid",
  "tenant": "teranga-cereales",
  "aud": "b2b",
  "exp": 1752003600,
  "iat": 1752000000
}
Claim Description
iss Toujours dynors-auth
sub UUID du sujet (user, customer, courier selon aud)
tenant Code tenant rattaché (peut être null pour B2C pur)
aud Realm — b2b (employés de tenant), b2c-<app> (clients finaux d'un produit), internal (employés DYNORS), tracking (flux publics éphémères)
exp/iat Expiration courte (15-60 min selon realm) / émission

Tous les rôles, toutes les permissions, tous les produits accessibles se résolvent à la volée via dynors-entitlements — jamais depuis le JWT.

Avantage : révocation d'un droit en quelques secondes (pas d'attente d'expiration de token), JWT léger, contrats commerciaux changeables sans toucher à l'auth.


8. Pattern de consommation côté app métier

Une app DYNORS qui veut autoriser une action fait toujours la même chose :

@Service
@RequiredArgsConstructor
public class EntitlementsClient {

    private final InterAppCallService interApp;
    private final Cache<String, EntitlementCheckResponse> cache;
        // expireAfterWrite(60, SECONDS)

    public EntitlementCheckResponse check(String subjectType,
                                          String subjectId,
                                          String scope) {
        return cache.get(key(subjectType, subjectId, scope), k ->
            interApp.get(
                "/entitlements/check?subject_type=" + subjectType
                + "&subject_id=" + subjectId
                + "&scope=" + scope,
                EntitlementCheckResponse.class,
                TenantContext.get()
            )
        );
    }
}

Et dans le filtre métier :

@PreAuthorize("@entitlementsGuard.canAccess(authentication, 'app:supergest', 'STOCK_WRITE')")
@PostMapping("/api/v1/stock/movements")
public ResponseEntity<MovementDto> create(...) { ... }

Le EntitlementsGuard appelle entitlementsClient.check(...) et retourne true uniquement si allowed == true ET permissions contient STOCK_WRITE.


9. Audit et conformité (banque/assurance)

Critique en environnement régulé : chaque décision d'entitlement est tracée.

TABLE entitlement_decision_log
──────────────────────────────────────────────────────
id, occurred_at, subject_type, subject_id, scope,
allowed (bool), reason, permissions, source, source_ref,
caller_app, caller_request_id, latency_ms
──────────────────────────────────────────────────────

Conservation : 7 ans (alignement obligations bancaires/assurances Sénégal). Indexation sur subject_id, caller_app, occurred_at pour répondre aux audits internes et externes (DGI, BCEAO si applicable, audits ISO 27001 / SOC 2 prévus).

Chaque changement de source (révocation, suspension, grant) déclenche un event audit.entitlement.changed consommé par HEISENBERG (BI) et stocké en archive immuable (S3 Object Lock 7 ans).


10. Configuration et déploiement

Attribut Valeur
Repo dynors-platform — module entitlements
Code app (source-app) entitlements
Route SLY /entitlements/**
Port dev 8092
Base de données PostgreSQL dynors_entitlements_db
Cache Redis (clé : ent:{subject_type}:{subject_id}:{scope}, TTL 60s)
Bus events RabbitMQ ou Kafka selon environnement (dev : in-memory)
SlyTransitFilter Obligatoire — appelé exclusivement par les apps via SLY

10.1 Variables d'environnement

Variable Source Vault Description
ENT_DB_URL dynors/{env}/entitlements/database JDBC URL
ENT_DB_USER dynors/{env}/entitlements/database Utilisateur
ENT_DB_PASSWORD dynors/{env}/entitlements/database Mot de passe
ENT_REDIS_URL dynors/{env}/entitlements/redis Redis URL
EVENTS_BROKER_URL dynors/{env}/platform/events RabbitMQ/Kafka
SLY_FORWARD_SECRET dynors/{env}/platform/sly HMAC transit

10.2 Route SLY

Dans dynors-platform/selebeyone/application.yml :

dynors:
  sly:
    routes:
      - id: entitlements
        path: /entitlements/**
        backend: ${ENT_BASE_URL:http://dynors-entitlements:8092}
        strip-prefix: false
        require-sly-pass: true
        require-transit-hmac: true   # Appel inter-app uniquement

11. Frontières avec dynors-billing — qui fait quoi

C'est le point le plus important pour éviter la confusion. Tableau de séparation stricte :

Sujet dynors-billing dynors-entitlements
Plans tarifaires SaaS, formules, paliers ✅ Maître ❌ Ignore
Création / renouvellement / résiliation de contrat ✅ Maître ❌ Ignore
Calcul du montant à facturer (prorata, sièges) ✅ Maître ❌ Ignore
Émission factures pro forma, factures finales ✅ Maître (via FISCAL) ❌ Ignore
Dunning (relances impayés) et suspension contractuelle ✅ Maître ➡️ Reçoit tenant.contract.suspended
Décision « ce user peut faire telle action maintenant » ❌ Ignore ✅ Maître
Achats B2C transactionnels (DAWALALE packs, Mediconnect actes) ❌ Ignore ✅ Maître (via events apps)
Onboarding sans abonnement (ELISA tenants) ❌ Ignore ✅ Maître (via events ELISA)
Grants administratifs (démos, free trial) ❌ Ignore ✅ Maître (via API admin)
Cache des droits côté apps ❌ Ignore ✅ Maître
Audit décisions d'autorisation ❌ Ignore ✅ Maître

dynors-billing est maître du B2B SaaS récurrent — pas plus, pas moins. Il publie des events ; dynors-entitlements les consomme parmi d'autres.


12. Sujets connexes — non couverts ici

Sujet Document dédié (à venir ou existant)
Modèle tarifaire FISCAL (Stripe-like : base + commission par facture, paliers de volume) mecanisme-tarification-fiscal.md (à rédiger)
dynors-billing — plans, contrats, dunning, prorata 04-bibliotheque-projets/applications/dynors-billing.md
Émission de factures (B2B + B2C + transactionnel) mecanisme-facturation-pdf-media.md
Encaissements PSP (Wave, OM, etc.) 04-bibliotheque-projets/applications/dynors-paiement.md
Authentification (identité uniquement) dynors-auth.md

13. Checklist d'intégration pour une nouvelle app

  • [ ] Identifier le modèle commercial de l'app : B2B SaaS / B2B onboardé / B2C transactionnel / mix
  • [ ] Lister les scopes d'entitlement que l'app publie (app:<code>, feature:<...>, tier:<...>, course:<...> etc.)
  • [ ] Définir les events producteurs que l'app va publier (<app>.<entité>.<action>)
  • [ ] Inclure le client EntitlementsClient (via dynors-commons ou local) dans l'app
  • [ ] Mettre en place le cache local (60s) et la consommation de entitlement.invalidated
  • [ ] Tracer chaque décision d'autorisation dans les logs structurés (input pour audit)
  • [ ] Documenter le mapping scope → permissions dans le README de l'app

14. Références

Sujet Document
dynors-auth — authentification (identité) dynors-auth
dynors-billing — facturation B2B SaaS dynors-billing
SLY — gateway et routage Gateway SelebeYone
Sécurité & Auth — guide développeur Sécurité & Auth
Routage inter-app (table complète) Routage inter-app
Vault — stratégie secrets Vault — stratégie secrets