From ed0d74e124b9f2186a5770bf96554b6275e93cad Mon Sep 17 00:00:00 2001
From: dahoud <41957584+DahoudG@users.noreply.github.com>
Date: Sat, 25 Apr 2026 16:48:47 +0000
Subject: [PATCH] =?UTF-8?q?feat(sprint-17=20backend=202026-04-25):=20Publi?=
=?UTF-8?q?c=20KPI=20Sharing=20=E2=80=94=20token=20sign=C3=A9=20HMAC-SHA25?=
=?UTF-8?q?6=20+=20endpoints=20admin/public?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Transparency réglementaire (Sprint 16.D différé livré en S17). Permet aux autorités
externes (BCEAO, ARTCI, CENTIF, contrôleurs UEMOA) de consulter les KPI agrégés
d'une organisation sans login, via lien signé temporairement.
Architecture sécurité
- KpiShareTokenService : HMAC-SHA256 + base64url, format orgId.expiryMillis.signature
- Vérification time-constant via MessageDigest.isEqual (résistant timing attacks)
- Secret @ConfigProperty unionflow.kpi.share.secret (défaut TTL 7 jours)
- Pas de DB — token autosuffisant, révocable seulement par rotation du secret
KpiPublicService
- snapshotPublic(orgId) : extrait sous-ensemble safe de ComplianceSnapshot
- Audit chaque accès public (méta-traçabilité — qui/quand consulté quoi)
Endpoints
- GET /api/admin/kpi/share-link/{orgId}?ttlSeconds=...
@RolesAllowed ADMIN_ORGANISATION, PRESIDENT, COMPLIANCE_OFFICER, SUPER_ADMIN
Retourne {token, ttlSeconds, publicUrl, publicWebPath}
- GET /api/public/kpi?token=...
@PermitAll (whitelist /api/public/* dans application.properties)
Retourne KpiPublicSnapshot ou 401/404 si token invalide/expiré ou org introuvable
Bump dépendance api 1.0.9 → 1.0.10 (DTO KpiPublicSnapshot ajouté)
Tests KpiShareTokenService (9 tests)
- Round-trip orgId, ttl 0/<=0 défaut, orgId null exception
- Token expiré (signature fake), tampering signature, tampering orgId
- Malformé (< 3 parts, > 4 parts)
- Null/blank
- Encode/decode round-trip avec caractères spéciaux
ACTION USER : mvn install api 1.0.10 puis tester impl/web.
---
pom.xml | 2 +-
.../server/resource/KpiShareLinkResource.java | 52 ++++++++
.../server/resource/PublicKpiResource.java | 67 ++++++++++
.../service/compliance/KpiPublicService.java | 49 +++++++
.../compliance/KpiShareTokenService.java | 120 ++++++++++++++++++
src/main/resources/application.properties | 2 +-
.../compliance/KpiShareTokenServiceTest.java | 114 +++++++++++++++++
7 files changed, 404 insertions(+), 2 deletions(-)
create mode 100644 src/main/java/dev/lions/unionflow/server/resource/KpiShareLinkResource.java
create mode 100644 src/main/java/dev/lions/unionflow/server/resource/PublicKpiResource.java
create mode 100644 src/main/java/dev/lions/unionflow/server/service/compliance/KpiPublicService.java
create mode 100644 src/main/java/dev/lions/unionflow/server/service/compliance/KpiShareTokenService.java
create mode 100644 src/test/java/dev/lions/unionflow/server/service/compliance/KpiShareTokenServiceTest.java
diff --git a/pom.xml b/pom.xml
index 17d944c..ddd4610 100644
--- a/pom.xml
+++ b/pom.xml
@@ -61,7 +61,7 @@
Crée un token signé HMAC-SHA256 valide pendant {@code ttlSeconds} (défaut 7 jours)
+ * permettant à une autorité externe de consulter les KPI agrégés sans login.
+ *
+ * @since 2026-04-25 (Sprint 17)
+ */
+@Path("/api/admin/kpi/share-link")
+@Produces(MediaType.APPLICATION_JSON)
+@Authenticated
+public class KpiShareLinkResource {
+
+ @Inject KpiShareTokenService tokenService;
+ @Inject AuditTrailService auditTrail;
+
+ @GET
+ @Path("/{orgId}")
+ @RolesAllowed({"ADMIN_ORGANISATION", "PRESIDENT", "COMPLIANCE_OFFICER", "SUPER_ADMIN"})
+ public Map Pas de @Authenticated : accès anonyme avec token signé HMAC-SHA256. Toute requête
+ * (succès ou échec) est tracée dans l'audit trail pour transparency.
+ *
+ * @since 2026-04-25 (Sprint 17)
+ */
+@Path("/api/public/kpi")
+@Produces(MediaType.APPLICATION_JSON)
+@PermitAll
+public class PublicKpiResource {
+
+ private static final Logger LOG = Logger.getLogger(PublicKpiResource.class);
+
+ @Inject KpiShareTokenService tokenService;
+ @Inject KpiPublicService kpiService;
+
+ @GET
+ public Response consulter(@QueryParam("token") String token) {
+ if (token == null || token.isBlank()) {
+ return Response.status(Response.Status.BAD_REQUEST)
+ .entity(java.util.Map.of("error", "token query param requis"))
+ .build();
+ }
+ var orgIdOpt = tokenService.verifier(token);
+ if (orgIdOpt.isEmpty()) {
+ LOG.warnf("PublicKpi: token invalide ou expiré");
+ return Response.status(Response.Status.UNAUTHORIZED)
+ .entity(java.util.Map.of("error", "Token invalide ou expiré"))
+ .build();
+ }
+
+ UUID orgId = orgIdOpt.get();
+ try {
+ KpiPublicSnapshot snap = kpiService.snapshotPublic(orgId, "PUBLIC_TOKEN");
+ return Response.ok(snap).build();
+ } catch (IllegalArgumentException e) {
+ LOG.warnf("PublicKpi: org %s introuvable", orgId);
+ return Response.status(Response.Status.NOT_FOUND)
+ .entity(java.util.Map.of("error", "Organisation introuvable"))
+ .build();
+ } catch (Exception e) {
+ LOG.errorf(e, "PublicKpi: erreur snapshot org=%s", orgId);
+ return Response.serverError()
+ .entity(java.util.Map.of("error", "Erreur interne"))
+ .build();
+ }
+ }
+}
diff --git a/src/main/java/dev/lions/unionflow/server/service/compliance/KpiPublicService.java b/src/main/java/dev/lions/unionflow/server/service/compliance/KpiPublicService.java
new file mode 100644
index 0000000..807cc10
--- /dev/null
+++ b/src/main/java/dev/lions/unionflow/server/service/compliance/KpiPublicService.java
@@ -0,0 +1,49 @@
+package dev.lions.unionflow.server.service.compliance;
+
+import dev.lions.unionflow.server.api.dto.compliance.response.KpiPublicSnapshot;
+import dev.lions.unionflow.server.security.AuditTrailService;
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.inject.Inject;
+import java.time.LocalDateTime;
+import java.util.UUID;
+
+/**
+ * Service métier KPI public (Sprint 17) — extrait un sous-ensemble safe de
+ * {@link ComplianceDashboardService.ComplianceSnapshot} pour diffusion externe.
+ *
+ * @since 2026-04-25 (Sprint 17)
+ */
+@ApplicationScoped
+public class KpiPublicService {
+
+ @Inject ComplianceDashboardService dashboardService;
+ @Inject AuditTrailService auditTrail;
+
+ /**
+ * Construit le snapshot public pour une organisation.
+ * Audit chaque accès public pour traçabilité (transparency méta).
+ */
+ public KpiPublicSnapshot snapshotPublic(UUID organisationId, String accessSource) {
+ var snap = dashboardService.snapshot(organisationId);
+
+ auditTrail.logSimple("KpiPublicSnapshot", organisationId, "EXPORT",
+ "Accès KPI public via " + (accessSource == null ? "token" : accessSource));
+
+ return KpiPublicSnapshot.builder()
+ .organisationNom(snap.organisationNom())
+ .referentielComptable(snap.referentielComptable() == null ? null
+ : snap.referentielComptable().name())
+ .scoreGlobal(snap.scoreGlobal())
+ .complianceOfficerDesigne(snap.complianceOfficerDesigne())
+ .agAnnuelleStatut(snap.agAnnuelle() == null ? null : snap.agAnnuelle().statut())
+ .rapportAirmsStatut(snap.rapportAirms() == null ? null : snap.rapportAirms().statut())
+ .dirigeantsAvecCmu(snap.dirigeantsAvecCmu())
+ .tauxKycAJourPct(snap.tauxKycAJourPct())
+ .tauxFormationLbcFtPct(snap.tauxFormationLbcFtPct())
+ .couvertureUboPct(snap.couvertureUboPct())
+ .commissaireAuxComptesStatut(snap.commissaireAuxComptes() == null ? null
+ : snap.commissaireAuxComptes().statut())
+ .dateGeneration(LocalDateTime.now())
+ .build();
+ }
+}
diff --git a/src/main/java/dev/lions/unionflow/server/service/compliance/KpiShareTokenService.java b/src/main/java/dev/lions/unionflow/server/service/compliance/KpiShareTokenService.java
new file mode 100644
index 0000000..d355436
--- /dev/null
+++ b/src/main/java/dev/lions/unionflow/server/service/compliance/KpiShareTokenService.java
@@ -0,0 +1,120 @@
+package dev.lions.unionflow.server.service.compliance;
+
+import jakarta.enterprise.context.ApplicationScoped;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.time.Instant;
+import java.util.Base64;
+import java.util.Optional;
+import java.util.UUID;
+import javax.crypto.Mac;
+import javax.crypto.spec.SecretKeySpec;
+import org.eclipse.microprofile.config.inject.ConfigProperty;
+import org.jboss.logging.Logger;
+
+/**
+ * Génération et validation des tokens signés pour partage KPI public (Sprint 17).
+ *
+ * Format token : {@code base64url(orgId)} + {@code .} + {@code base64url(expiryEpochMs)}
+ * + {@code .} + {@code base64url(HMAC-SHA256(orgId.expiryEpochMs))}.
+ *
+ * Vérification : recalcule HMAC, compare en time-constant ({@link MessageDigest#isEqual}),
+ * vérifie expiry. Aucune base de données — token autosuffisant et révocable uniquement
+ * par rotation du secret.
+ *
+ * @since 2026-04-25 (Sprint 17)
+ */
+@ApplicationScoped
+public class KpiShareTokenService {
+
+ private static final Logger LOG = Logger.getLogger(KpiShareTokenService.class);
+ private static final String HMAC_ALGO = "HmacSHA256";
+ static final long DEFAULT_TTL_SECONDS = 7 * 24 * 3600L; // 7 jours
+
+ @ConfigProperty(name = "unionflow.kpi.share.secret",
+ defaultValue = "CHANGE-ME-IN-PROD-32-CHARS-MIN-OK")
+ String secret;
+
+ /**
+ * Génère un token signé pour partage KPI d'une organisation pendant {@code ttlSeconds}.
+ * Si {@code ttlSeconds <= 0}, applique le défaut (7 jours).
+ */
+ public String generer(UUID organisationId, long ttlSeconds) {
+ if (organisationId == null) throw new IllegalArgumentException("organisationId null");
+ long ttl = ttlSeconds > 0 ? ttlSeconds : DEFAULT_TTL_SECONDS;
+ long expiryMs = Instant.now().plusSeconds(ttl).toEpochMilli();
+
+ String orgEncoded = encode(organisationId.toString());
+ String expEncoded = encode(Long.toString(expiryMs));
+ String payload = orgEncoded + "." + expEncoded;
+ String signature = encode(hmac(payload));
+ return payload + "." + signature;
+ }
+
+ /**
+ * Vérifie un token. Retourne l'orgId si valide et non expiré.
+ * Sinon {@link Optional#empty()}.
+ */
+ public Optional