diff --git a/ManagerService/Controllers/ConfigurationController.cs b/ManagerService/Controllers/ConfigurationController.cs
index e50fc0b..c89c7fb 100644
--- a/ManagerService/Controllers/ConfigurationController.cs
+++ b/ManagerService/Controllers/ConfigurationController.cs
@@ -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;
@@ -379,14 +380,28 @@ namespace ManagerService.Controllers
/// Export a configuration
///
/// Id of configuration to export
- /// Language to export
- [Authorize(Policy = ManagerService.Service.Security.Policies.AppReadAccess)]
+ /// Language to export
+ ///
+ /// Ouverte aux apps visiteur par X-Api-Key : c'est l'appel que fait
+ /// mymuseum-visitapp pour télécharger une visite hors ligne.
+ ///
+ /// ⚠️ [Authorize(AppReadAccess)] ne suffisait pas : ASP.NET Core **combine**
+ /// les [Authorize] de la classe et de l'action. Le contrôleur exige
+ /// ContentEditor, 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 [AllowAnonymous]
+ /// court-circuite la policy du contrôleur ; le contrôle d'accès se fait ici.
+ /// Même correctif que .
+ ///
+ [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 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 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 };
}
}