Ouvrir le détail d'instance aux apps visiteur, en vue réduite

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 <noreply@anthropic.com>
This commit is contained in:
Thomas Fransolet 2026-09-08 12:55:36 +02:00
parent 82e61dcded
commit 847f81393b

View File

@ -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
/// </summary>
/// <param name="id">id instance</param>
/// <remarks>
/// Ouverte aux apps visiteur par <c>X-Api-Key</c> : 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.
/// </remarks>
[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<ObjectResult> 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
/// </summary>
/// <param name="pinCode">Instance PIN code</param>
/// <param name="appType">App type (VisitApp, TabletApp, Other)</param>
/// <summary>
/// Retire d'un <see cref="InstanceDTO"/> 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.
/// </summary>
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)]