feat(sprint-17 backend 2026-04-25): Public KPI Sharing — token signé HMAC-SHA256 + endpoints admin/public
Some checks failed
CI/CD Pipeline / pipeline (push) Failing after 3m3s
Some checks failed
CI/CD Pipeline / pipeline (push) Failing after 3m3s
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.
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
package dev.lions.unionflow.server.resource;
|
||||
|
||||
import dev.lions.unionflow.server.security.AuditTrailService;
|
||||
import dev.lions.unionflow.server.service.compliance.KpiShareTokenService;
|
||||
import io.quarkus.security.Authenticated;
|
||||
import jakarta.annotation.security.RolesAllowed;
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.PathParam;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.QueryParam;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Génération de liens KPI signés (Sprint 17) — admin uniquement.
|
||||
*
|
||||
* <p>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<String, Object> generer(
|
||||
@PathParam("orgId") UUID orgId,
|
||||
@QueryParam("ttlSeconds") Long ttlSeconds) {
|
||||
|
||||
long ttl = ttlSeconds == null ? KpiShareTokenService.DEFAULT_TTL_SECONDS : ttlSeconds;
|
||||
String token = tokenService.generer(orgId, ttl);
|
||||
|
||||
auditTrail.logSimple("KpiShareLink", orgId, "CREATE",
|
||||
"Lien KPI public généré (TTL " + ttl + "s)");
|
||||
|
||||
return Map.of(
|
||||
"token", token,
|
||||
"ttlSeconds", ttl,
|
||||
"publicUrl", "/api/public/kpi?token=" + token,
|
||||
"publicWebPath", "/pages/public/kpi?token=" + token);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package dev.lions.unionflow.server.resource;
|
||||
|
||||
import dev.lions.unionflow.server.api.dto.compliance.response.KpiPublicSnapshot;
|
||||
import dev.lions.unionflow.server.service.compliance.KpiPublicService;
|
||||
import dev.lions.unionflow.server.service.compliance.KpiShareTokenService;
|
||||
import io.quarkus.security.PermissionsAllowed;
|
||||
import jakarta.annotation.security.PermitAll;
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.QueryParam;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import java.util.UUID;
|
||||
import org.jboss.logging.Logger;
|
||||
|
||||
/**
|
||||
* Endpoint public KPI (Sprint 17) — consommation par autorités externes via token signé.
|
||||
*
|
||||
* <p>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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user