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-entitlementsest 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-billingreste maître du B2B récurrent (factures pro forma, renouvellements, dunning, prorata, gestion des sièges, suspension pour impayé).dynors-entitlementsest la projection runtime « qui peut accéder à quoi en ce moment », alimentée par plusieurs producteurs dontdynors-billingn'est qu'un cas particulier.dynors-authreste 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) → statusSUSPENDED(lecture seule possible côté apps), réactivable au paiementtenant.contract.cancelled→ statusREVOKED
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.activatedavectier: 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)avecexpires_atselon la durée du pack etpermissionslistant 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})avecexpires_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(viadynors-commonsou 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 → permissionsdans 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 |