From 847f81393b6d348efee37dfd757ebb5422e357e2 Mon Sep 17 00:00:00 2001 From: Thomas Fransolet Date: Tue, 8 Sep 2026 12:55:36 +0200 Subject: [PATCH] =?UTF-8?q?Ouvrir=20le=20d=C3=A9tail=20d'instance=20aux=20?= =?UTF-8?q?apps=20visiteur,=20en=20vue=20r=C3=A9duite?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mymuseum-visitapp appelle GET /api/Instance/{id} au démarrage pour lire la voix du guide, et recevait un 403 : tout InstanceController porte [Authorize(SuperAdmin)] et GetDetail n'avait pas d'exception, contrairement à slug, byPin et app-key. Une clé API donne désormais accès à SON instance seulement — clé croisée = 403 — et à une vue réduite. StripCommercialFields retire le plan, les quotas, l'usage IA, l'essai, la TVA, la facturation et le pinCode, qui ouvre l'appairage des tablettes. Un utilisateur du manager continue de tout voir. ⚠️ Le test « est-ce un utilisateur du manager » ne peut PAS se baser sur un claim de permission. AuthorizationMiddleware authentifie avec les schémas de la policy du contrôleur — JwtBearer ET ApiKey — et peuple HttpContext.User AVANT de court-circuiter sur [AllowAnonymous]. Une clé API produit donc un User authentifié auquel le handler pose le claim Viewer : la première version du correctif laissait passer tout le monde, et ne se voyait pas sur une instance dont les champs sensibles sont naturellement nuls. Le test porte donc sur le schéma d'authentification. Vérifié sur la préprod, cinq cas : 401 sans clé, 200 sur sa propre instance, 403 en croisé dans les deux sens, 200 pour le manager avec le DTO complet. Co-Authored-By: Claude Opus 5 --- .../Controllers/InstanceController.cs | 72 ++++++++++++++++++- 1 file changed, 70 insertions(+), 2 deletions(-) diff --git a/ManagerService/Controllers/InstanceController.cs b/ManagerService/Controllers/InstanceController.cs index 1938a0e..edf2654 100644 --- a/ManagerService/Controllers/InstanceController.cs +++ b/ManagerService/Controllers/InstanceController.cs @@ -8,6 +8,7 @@ using ManagerService.Data; using ManagerService.DTOs; using ManagerService.Helpers; using ManagerService.Services; +using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; @@ -70,14 +71,48 @@ namespace ManagerService.Controllers /// Get a specific instance /// /// id instance + /// + /// Ouverte aux apps visiteur par X-Api-Key : mymuseum-visitapp appelle cette + /// route au démarrage pour lire la voix du guide, et recevait un 403 — tout le + /// contrôleur exige SuperAdmin. Une clé API ne donne accès qu'à SON instance, et à + /// une vue réduite : plan, quotas, TVA, facturation et pinCode ne sortent que pour + /// un utilisateur du manager. + /// + [AllowAnonymous] [ProducesResponseType(typeof(InstanceDTO), 200)] + [ProducesResponseType(typeof(string), 401)] + [ProducesResponseType(typeof(string), 403)] [ProducesResponseType(typeof(string), 404)] [ProducesResponseType(typeof(string), 500)] [HttpGet("{id}")] - public ObjectResult GetDetail(string id) + public async Task GetDetail(string id) { try { + // Le schéma ApiKey n'est pas le schéma par défaut : sur une action + // [AllowAnonymous] il faut le déclencher explicitement. + var apiKeyAuth = await HttpContext.AuthenticateAsync("ApiKey"); + var keyInstanceId = apiKeyAuth.Succeeded + ? apiKeyAuth.Principal?.FindFirst(ManagerService.Service.Security.ClaimTypes.InstanceId)?.Value + : null; + + // ⚠️ Ne PAS déduire « utilisateur du manager » d'un claim de permission. + // AuthorizationMiddleware authentifie avec les schémas de la policy du + // contrôleur — JwtBearer ET ApiKey (Startup.cs:133) — et peuple + // HttpContext.User AVANT de court-circuiter sur [AllowAnonymous]. Une clé + // API donne donc un User authentifié, et le handler lui pose le claim + // Viewer : le test « a Viewer » était vrai pour tout le monde. + var isManager = !apiKeyAuth.Succeeded + && User?.Identity?.IsAuthenticated == true + && User.HasClaim(ManagerService.Service.Security.ClaimTypes.Permission, + ManagerService.Service.Security.Permissions.Viewer); + + if (!isManager && keyInstanceId == null) + return new ObjectResult("Authentication required") { StatusCode = 401 }; + + if (!isManager && keyInstanceId != id) + return new ObjectResult("This API key does not grant access to this instance") { StatusCode = 403 }; + Instance instance = _myInfoMateDbContext.Instances.FirstOrDefault(i => i.Id == id); //OldInstance instance = _instanceService.GetById(id); @@ -87,7 +122,12 @@ namespace ManagerService.Controllers var applicationInstances = _myInfoMateDbContext.ApplicationInstances.Where(ai => ai.InstanceId == instance.Id).ToList(); - return new OkObjectResult(instance.ToDTO(applicationInstances.Select(ai => ai.ToDTO(_myInfoMateDbContext)).ToList())); + var dto = instance.ToDTO(applicationInstances.Select(ai => ai.ToDTO(_myInfoMateDbContext)).ToList()); + + if (!isManager) + StripCommercialFields(dto); + + return new OkObjectResult(dto); } catch (KeyNotFoundException ex) { @@ -339,6 +379,34 @@ namespace ManagerService.Controllers /// /// Instance PIN code /// App type (VisitApp, TabletApp, Other) + + /// + /// Retire d'un tout ce qui ne regarde pas une app visiteur. + /// + /// Ce que l'app garde : identité, drapeaux de canal, réglages du guide, webSlug, + /// publicApiKey (elle la détient déjà pour appeler) et les ApplicationInstances. + /// Ce qui part : le commercial et le pinCode, qui ouvre l'appairage des tablettes. + /// + private static void StripCommercialFields(InstanceDTO dto) + { + dto.pinCode = null; + dto.subscriptionPlanId = null; + dto.subscriptionPlan = null; + dto.aiTokensThisMonth = null; + dto.aiUsageMonthKey = null; + dto.storageQuotaBytes = null; + dto.aiTokensPerMonth = null; + dto.hasStats = null; + dto.statsHistoryDays = null; + dto.hasAdvancedStats = null; + dto.isTrialActive = null; + dto.trialEndsAt = null; + dto.trialAiTokensUsed = null; + dto.billingAddress = null; + dto.billingCountry = null; + dto.vatNumber = null; + dto.vatRate = null; + } [AllowAnonymous] [ProducesResponseType(typeof(object), 200)] [ProducesResponseType(typeof(string), 404)]