Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
// Copyright 2022 Carnegie Mellon University. All Rights Reserved.
// Released under a MIT (SEI)-style license, please see LICENSE.md in the project root for license information or contact permission@sei.cmu.edu for full terms.

using System;
using System.Net;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Hosting;
using System.Text.Json;
using Microsoft.AspNetCore.Mvc.Infrastructure;
using Microsoft.EntityFrameworkCore;
using Npgsql;
using Blueprint.Api.Infrastructure.Exceptions;

namespace Blueprint.Api.Infrastructure.Exceptions.Middleware
{
public class ExceptionMiddleware
{
private readonly IWebHostEnvironment _env;
private readonly RequestDelegate _next;
private readonly ILogger _logger;
private readonly ProblemDetailsFactory _problemDetailsFactory;

public ExceptionMiddleware(
RequestDelegate next,
ILogger<ExceptionMiddleware> logger,
IWebHostEnvironment env,
ProblemDetailsFactory problemDetailsFactory)
{
_logger = logger;
_next = next;
_env = env;
_problemDetailsFactory = problemDetailsFactory;
}

public async Task InvokeAsync(HttpContext httpContext)
{
try
{
await _next(httpContext);
}
catch (Exception ex)
{
// Transform PostgreSQL errors into clear messages
if (ex is DbUpdateException dbEx && dbEx.InnerException is PostgresException pgEx)
{
ex = TransformPostgresException(pgEx);
}

_logger.LogError($"Unhandled Exception: {ex}");

await HandleExceptionAsync(httpContext, ex);
}
}

private async Task HandleExceptionAsync(HttpContext context, Exception exception)
{
int statusCode = GetStatusCodeFromException(exception);

var error = new ProblemDetails();
error.Status = statusCode;

context.Response.StatusCode = statusCode;
context.Response.ContentType = "application/problem+json";

if (statusCode == (int)HttpStatusCode.InternalServerError)
{
if (_env.IsDevelopment())
{
error.Title = exception.Message;
error.Detail = exception.ToString();
}
else
{
error.Title = "A server error occurred.";
error.Detail = exception.Message;
}
}
else
{
error.Title = exception.Message;
}

await context.Response.WriteAsync(JsonSerializer.Serialize(error));
}

/// <summary>
/// map all custom exceptions to proper http status code
/// </summary>
/// <returns></returns>
private int GetStatusCodeFromException(Exception exception)
{
HttpStatusCode statusCode = HttpStatusCode.InternalServerError;

if (exception is IApiException)
{
statusCode = (exception as IApiException).GetStatusCode();
}

return (int)statusCode;
}

/// <summary>
/// Transform PostgreSQL exceptions into user-friendly messages.
/// Logs detailed error information while returning generic messages to prevent
/// exposing database internals to users.
/// </summary>
private Exception TransformPostgresException(PostgresException pgEx)
{
// Log detailed error for developers/ops
_logger.LogError($"PostgreSQL {pgEx.SqlState}: Table={pgEx.TableName}, Constraint={pgEx.ConstraintName}, Message={pgEx.MessageText}");

// Always return generic user-friendly messages
return pgEx.SqlState switch
{
"23505" => // unique_violation
new InvalidOperationException("A record with this identifier already exists."),

"23503" => // foreign_key_violation
new InvalidOperationException("Referenced entity does not exist. Please verify all referenced entities exist."),

"23514" => // check_violation
new InvalidOperationException("Data validation failed."),

_ => new InvalidOperationException("A database error occurred.")
};
}
}
}
70 changes: 0 additions & 70 deletions Blueprint.Api/Infrastructure/Filters/JsonExceptionFilter.cs

This file was deleted.

22 changes: 20 additions & 2 deletions Blueprint.Api/Services/CardService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
using System.Threading.Tasks;
using AutoMapper;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Npgsql;
using Blueprint.Api.Data;
using Blueprint.Api.Data.Models;
using Blueprint.Api.Infrastructure.Authorization;
Expand All @@ -34,15 +36,18 @@ public class CardService : ICardService
private readonly BlueprintContext _context;
private readonly ClaimsPrincipal _user;
private readonly IMapper _mapper;
private readonly ILogger<CardService> _logger;

public CardService(
BlueprintContext context,
IPrincipal user,
IMapper mapper)
IMapper mapper,
ILogger<CardService> logger)
{
_context = context;
_user = user as ClaimsPrincipal;
_mapper = mapper;
_logger = logger;
}

public async Task<IEnumerable<ViewModels.Card>> GetTemplatesAsync(CancellationToken ct)
Expand Down Expand Up @@ -95,14 +100,27 @@ public CardService(
if (!hasGalleryCardPermission)
throw new ForbiddenException();
}

// Validate required fields
if (string.IsNullOrWhiteSpace(card.Name))
throw new ArgumentException("Card Name is required and cannot be empty.");

// Validate MselId if provided
if (card.MselId.HasValue && card.MselId.Value != Guid.Empty)
{
var mselExists = await _context.Msels.AnyAsync(m => m.Id == card.MselId.Value, ct);
if (!mselExists)
throw new EntityNotFoundException<Msel>($"Invalid MselId '{card.MselId}'. The MSEL does not exist.");
}

card.Id = card.Id != Guid.Empty ? card.Id : Guid.NewGuid();
card.CreatedBy = _user.GetId();
var cardEntity = _mapper.Map<CardEntity>(card);

_context.Cards.Add(cardEntity);
await _context.SaveChangesAsync(ct);
card = await GetAsync(cardEntity.Id, true, ct);

card = await GetAsync(cardEntity.Id, true, ct);
return card;
}

Expand Down
7 changes: 6 additions & 1 deletion Blueprint.Api/Services/MselService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
using AutoMapper;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Npgsql;
using Blueprint.Api.Data;
using Blueprint.Api.Data.Enumerations;
using Blueprint.Api.Data.Models;
Expand Down Expand Up @@ -243,14 +244,18 @@ public MselService(

public async Task<ViewModels.Msel> CreateAsync(ViewModels.Msel msel, CancellationToken ct)
{
// Validate required fields
if (string.IsNullOrWhiteSpace(msel.Name))
throw new ArgumentException("MSEL Name is required and cannot be empty.");

msel.Id = msel.Id != Guid.Empty ? msel.Id : Guid.NewGuid();
msel.CreatedBy = _user.GetId();
var mselEntity = _mapper.Map<MselEntity>(msel);

_context.Msels.Add(mselEntity);
await _context.SaveChangesAsync(ct);
msel = await GetAsync(mselEntity.Id, true, ct);

msel = await GetAsync(mselEntity.Id, true, ct);
return msel;
}

Expand Down
13 changes: 13 additions & 0 deletions Blueprint.Api/Services/TeamService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
using AutoMapper.QueryableExtensions;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Npgsql;
using Blueprint.Api.Data;
using Blueprint.Api.Data.Models;
using Blueprint.Api.Infrastructure.Extensions;
Expand Down Expand Up @@ -107,6 +108,18 @@ public TeamService(BlueprintContext context, IPrincipal team, ILogger<ITeamServi
)
throw new ForbiddenException();

// Validate required fields
if (string.IsNullOrWhiteSpace(team.Name))
throw new ArgumentException("Team Name is required and cannot be empty.");

if (team.MselId == Guid.Empty)
throw new ArgumentException("MselId is required and cannot be empty.");

// Validate MselId exists
var mselExists = await _context.Msels.AnyAsync(m => m.Id == team.MselId, ct);
if (!mselExists)
throw new EntityNotFoundException<Msel>($"Invalid MselId '{team.MselId}'. The MSEL does not exist.");

team.Id = team.Id != Guid.Empty ? team.Id : Guid.NewGuid();
team.CreatedBy = _user.GetId();
var teamEntity = _mapper.Map<TeamEntity>(team);
Expand Down
6 changes: 1 addition & 5 deletions Blueprint.Api/Startup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,6 @@ public void ConfigureServices(IServiceCollection services)
services.AddMvc(options =>
{
options.Filters.Add(typeof(ValidateModelStateFilter));
options.Filters.Add(typeof(JsonExceptionFilter));

// Require all scopes in authOptions
var policyBuilder = new AuthorizationPolicyBuilder().RequireAuthenticatedUser();
Expand Down Expand Up @@ -266,10 +265,7 @@ public void ConfigureServices(IServiceCollection services)
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
app.UseMiddleware<Blueprint.Api.Infrastructure.Exceptions.Middleware.ExceptionMiddleware>();
app.UsePathBase(_pathbase);
app.UseRouting();
app.UseCors("default");
Expand Down
Loading