Ouvrir l'export de configuration aux apps visiteur

`Configuration/{id}/export` portait `[Authorize(AppReadAccess)]`, mais ASP.NET
Core combine les `[Authorize]` de la classe et de l'action : le controleur exige
`ContentEditor`, qu'une cle API n'a pas. La cle authentifiait la requete, puis
l'autorisation la refusait — 403 sans corps. Cote mymuseum-visitapp, le
telechargement d'une visite echouait donc systematiquement.

Seul `[AllowAnonymous]` court-circuite la policy du controleur ; le controle
d'acces se fait dans l'action, qui declenche le schema ApiKey explicitement et
verifie que la cle porte bien l'instance de la configuration demandee. Meme
correctif que InstanceController.GetDetail.

Les trois `catch` renvoyaient `null` : l'app recevait un 200 vide et croyait la
visite exportee. Ils renvoient les codes qui etaient deja ecrits, en commentaire.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Thomas Fransolet 2026-09-08 15:03:42 +02:00
parent 1e1a36ad5a
commit 9cc45c5767

View File

@ -12,6 +12,7 @@ using ManagerService.Data;
using ManagerService.Data.SubSection;
using ManagerService.DTOs;
using ManagerService.Services;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;
@ -380,13 +381,27 @@ namespace ManagerService.Controllers
/// </summary>
/// <param name="id">Id of configuration to export</param>
/// <param name="language">Language to export</param>
[Authorize(Policy = ManagerService.Service.Security.Policies.AppReadAccess)]
/// <remarks>
/// Ouverte aux apps visiteur par <c>X-Api-Key</c> : c'est l'appel que fait
/// mymuseum-visitapp pour télécharger une visite hors ligne.
///
/// ⚠️ <c>[Authorize(AppReadAccess)]</c> ne suffisait pas : ASP.NET Core **combine**
/// les <c>[Authorize]</c> de la classe et de l'action. Le contrôleur exige
/// <c>ContentEditor</c>, qu'une clé API n'a pas — la clé authentifiait donc la
/// requête, puis l'autorisation la refusait : 403 sans corps, et côté app un
/// téléchargement qui échouait sans rien dire. Seul <c>[AllowAnonymous]</c>
/// court-circuite la policy du contrôleur ; le contrôle d'accès se fait ici.
/// Même correctif que <see cref="InstanceController.GetDetail"/>.
/// </remarks>
[AllowAnonymous]
[ProducesResponseType(typeof(FileContentResult), 200)]
[ProducesResponseType(typeof(string), 400)]
[ProducesResponseType(typeof(string), 401)]
[ProducesResponseType(typeof(string), 403)]
[ProducesResponseType(typeof(string), 404)]
[ProducesResponseType(typeof(string), 500)]
[HttpGet("{id}/export")]
public FileContentResult Export(string id, [FromQuery] string language)
public async Task<IActionResult> Export(string id, [FromQuery] string language)
{
try
{
@ -398,6 +413,26 @@ namespace ManagerService.Controllers
if (configuration == null)
throw new KeyNotFoundException("Configuration does not exist");
// 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 : le
// handler de clé API pose lui aussi le claim Viewer.
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 != configuration.InstanceId)
return new ObjectResult("This API key does not grant access to this configuration") { StatusCode = 403 };
// Les entités, pas seulement leurs DTO : la collecte des ressources passe
// par GetReferencedResourceIds, qui vit sur le sous-type.
List<Section> sections = _myInfoMateDbContext.Sections.Where(s => s.ConfigurationId == configuration.Id).ToList();
@ -436,20 +471,19 @@ namespace ManagerService.Controllers
FileDownloadName = fileName
};
}
// Les trois `catch` renvoyaient `null` : l'app recevait un 200 vide et croyait
// la visite exportée. Les codes ci-dessous étaient déjà écrits, en commentaire.
catch (ArgumentNullException ex)
{
return null;
//return new BadRequestObjectResult(ex.Message) { };
return new BadRequestObjectResult(ex.Message);
}
catch (KeyNotFoundException ex)
{
return null;
//return new NotFoundObjectResult(ex.Message) { };
return new NotFoundObjectResult(ex.Message);
}
catch (Exception ex)
{
return null;
//return new ObjectResult(ex.Message) { StatusCode = 500 };
return new ObjectResult(ex.Message) { StatusCode = 500 };
}
}