Files
Microsoft.SelfService.Porta…/Controllers/DeploymentArtifactController.cs
Torsten Brendgen dfd9c16f61 Add migration to update ApiClients with centralized authorization scopes
This migration updates the ScopesJson for the ApiClient with Id 91000000-0000-0000-0000-000000000001 to include additional authorization scopes for improved API access control. The Down method reverts the changes if necessary.
2026-07-09 23:49:01 +02:00

204 lines
8.0 KiB
C#

using AutoMapper;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.SelfService.Portal.Core.API.Authorization;
using Microsoft.SelfService.Portal.Core.API.Context;
using Microsoft.SelfService.Portal.Core.API.Dto.DeploymentArtifact.Add;
using Microsoft.SelfService.Portal.Core.API.Dto.DeploymentArtifact.Get;
using Microsoft.SelfService.Portal.Core.API.JsonDocuments;
using Microsoft.SelfService.Portal.Core.API.Models;
using System.Text.Json;
namespace Microsoft.SelfService.Portal.Core.API.Controllers
{
[Route("api/deployment-artifacts")]
[ApiController]
[Authorize(Policy = ApiPolicies.DeploymentRead)]
public class DeploymentArtifactController : Controller
{
private readonly DataContext _context;
private readonly IMapper _mapper;
public DeploymentArtifactController(DataContext context, IMapper mapper)
{
_context = context;
_mapper = mapper;
}
[HttpGet]
[ProducesResponseType(200, Type = typeof(IEnumerable<GetDeploymentArtifactDto>))]
public IActionResult GetDeploymentArtifacts([FromQuery] Guid? deploymentGroupId, [FromQuery] Guid? targetId)
{
var query = _context.DeploymentArtifacts
.AsNoTracking()
.AsQueryable();
if (deploymentGroupId.HasValue)
query = query.Where(artifact => artifact.DeploymentGroupId == deploymentGroupId.Value);
if (targetId.HasValue)
query = query.Where(artifact => artifact.TargetId == targetId.Value);
var artifacts = query
.OrderByDescending(artifact => artifact.Created)
.ToList();
return Ok(_mapper.Map<List<GetDeploymentArtifactDto>>(artifacts));
}
[HttpGet("{id}")]
[ProducesResponseType(200, Type = typeof(GetDeploymentArtifactDto))]
[ProducesResponseType(404)]
public IActionResult GetDeploymentArtifactById(Guid id)
{
var artifact = _context.DeploymentArtifacts
.AsNoTracking()
.FirstOrDefault(existing => existing.Id == id);
if (artifact == null)
return NotFound();
return Ok(_mapper.Map<GetDeploymentArtifactDto>(artifact));
}
[HttpPost]
[Authorize(Policy = ApiPolicies.DeploymentWrite)]
[ProducesResponseType(200, Type = typeof(Guid))]
[ProducesResponseType(400)]
public IActionResult AddDeploymentArtifact([FromBody] AddDeploymentArtifactDto deploymentArtifact)
{
if (deploymentArtifact == null)
return BadRequest(ModelState);
if (!_context.DeploymentGroups.Any(group => group.Id == deploymentArtifact.DeploymentGroupId))
{
ModelState.AddModelError(nameof(deploymentArtifact.DeploymentGroupId), "Deployment group was not found.");
return BadRequest(ModelState);
}
if (deploymentArtifact.TargetId.HasValue && !_context.Targets.Any(target => target.Id == deploymentArtifact.TargetId.Value))
{
ModelState.AddModelError(nameof(deploymentArtifact.TargetId), "Target was not found.");
return BadRequest(ModelState);
}
try
{
deploymentArtifact.DeploymentJson = NormalizeJson(deploymentArtifact.DeploymentJson);
deploymentArtifact.SourceJson = NormalizeOptionalJson(deploymentArtifact.SourceJson);
deploymentArtifact.ResolvedJson = NormalizeOptionalJson(deploymentArtifact.ResolvedJson);
deploymentArtifact.SourceSnapshotJson = NormalizeOptionalJson(deploymentArtifact.SourceSnapshotJson);
}
catch (ConfigurationDocumentValidationException ex)
{
ModelState.AddModelError(nameof(deploymentArtifact.DeploymentJson), ex.Message);
return BadRequest(ModelState);
}
var model = _mapper.Map<DeploymentArtifactModel>(deploymentArtifact);
model.Id = Guid.NewGuid();
model.InputHash = TemplateVersionModel.ComputeSha256(model.SourceJson ?? model.DeploymentJson);
model.OutputHash = TemplateVersionModel.ComputeSha256(model.ResolvedJson ?? model.DeploymentJson);
model.IsStale = false;
_context.DeploymentArtifacts.Add(model);
if (model.TargetId.HasValue)
{
var deployment = _context.Deployments.FirstOrDefault(existing =>
existing.DeploymentGroupId == model.DeploymentGroupId
&& existing.TargetId == model.TargetId.Value);
if (deployment != null)
{
deployment.CurrentArtifactId = model.Id;
deployment.SourceJson = model.SourceJson;
deployment.JSONData = model.DeploymentJson;
}
}
_context.SaveChanges();
return Ok(model.Id);
}
[HttpPost("{id}/stale-check")]
[Authorize(Policy = ApiPolicies.DeploymentWrite)]
[ProducesResponseType(200, Type = typeof(GetDeploymentArtifactDto))]
[ProducesResponseType(404)]
public IActionResult CheckDeploymentArtifactStale(Guid id)
{
var artifact = _context.DeploymentArtifacts.FirstOrDefault(existing => existing.Id == id);
if (artifact == null)
return NotFound();
var staleReasons = BuildStaleReasons(artifact);
artifact.IsStale = staleReasons.Count > 0;
artifact.StaleReasonJson = staleReasons.Count > 0
? JsonSerializer.Serialize(staleReasons)
: null;
_context.SaveChanges();
return Ok(_mapper.Map<GetDeploymentArtifactDto>(artifact));
}
private static string NormalizeJson(string json)
{
return ConfigurationDocumentValidator.NormalizeAndValidate(
string.IsNullOrWhiteSpace(json) ? "{}" : json,
ConfigurationDocumentKind.Metadata).JsonData;
}
private static string? NormalizeOptionalJson(string? json)
{
return string.IsNullOrWhiteSpace(json) ? null : NormalizeJson(json);
}
private List<object> BuildStaleReasons(DeploymentArtifactModel artifact)
{
var reasons = new List<object>();
if (string.IsNullOrWhiteSpace(artifact.SourceSnapshotJson))
{
return reasons;
}
using var snapshot = JsonDocument.Parse(artifact.SourceSnapshotJson);
if (!snapshot.RootElement.TryGetProperty("templateRevisions", out var revisions)
|| revisions.ValueKind != JsonValueKind.Array)
{
return reasons;
}
foreach (var source in revisions.EnumerateArray())
{
if (!source.TryGetProperty("id", out var idElement)
|| !Guid.TryParse(idElement.GetString(), out var revisionId)
|| !source.TryGetProperty("hash", out var hashElement))
{
continue;
}
var current = _context.TemplateRevisions
.AsNoTracking()
.FirstOrDefault(revision => revision.Id == revisionId);
if (current == null)
{
reasons.Add(new { Type = "TemplateRevisionMissing", RevisionId = revisionId });
continue;
}
var expectedHash = hashElement.GetString();
if (!string.Equals(current.JsonHash, expectedHash, StringComparison.OrdinalIgnoreCase))
{
reasons.Add(new { Type = "TemplateRevisionHashChanged", RevisionId = revisionId, OldHash = expectedHash, CurrentHash = current.JsonHash });
}
}
return reasons;
}
}
}