Skip to content
Open
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
14 changes: 14 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,20 @@ jobs:
name: web-dist
path: apps/web/dist

package-audit:
name: Package audit (dotnet)
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v5
with:
dotnet-version: '8.0.x'
- name: dotnet package list --outdated
run: dotnet package list --outdated --include-transitive --highest-patch

sbom:
name: Generate SBOM (repo)
runs-on: ubuntu-latest
Expand Down
23 changes: 23 additions & 0 deletions docs/maintenance/2024-11-maintenance-sweep.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Maintenance Sweep — November 2024

## Review highlights
- Output cache feature flag hardened so disabling the feature no longer breaks DI or endpoint caching configuration.
- Security headers middleware now runs earlier in the pipeline to guarantee headers on redirect responses.

## Outstanding issues to track
1. Minimal API endpoints always enabled output caching, which crashed when the feature flag was off (fixed in this sweep).
2. Security headers were applied after HTTPS redirection, so redirect responses missed CSP/HSTS hardening (fixed in this sweep).
3. Align `Microsoft.EntityFrameworkCore.Design` and `Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore` with the 8.0.11 patch that Infrastructure already consumes.
4. `Microsoft.AspNetCore.OutputCaching.StackExchangeRedis` is still on the 8.0.0 RTM build; bump to 8.0.11 for the latest fixes.
5. Test projects lag on EF Core packages (`InMemory`, `Microsoft.AspNetCore.Mvc.Testing`, `Microsoft.Data.Sqlite`)—all have 8.0.11 patches available.
6. Azure SDK dependencies (`Azure.Identity`, `Azure.Core`) should be checked for the latest servicing release; Dependabot will surface once policy allows.

## Dependency observations
- `.NET` — Recommend upgrading the EF Core and output caching packages noted above to their 8.0.11 service releases.
- `npm` — `npm outdated` shows React 18.x stack is current within its major; no action required now.
- Dependabot is enabled for NuGet, npm, and GitHub Actions and will keep surfacing new advisories.

## Next steps
- Schedule a follow-up PR to apply the recommended package bumps and regenerate lock files once a .NET SDK is available in CI.
- Review outstanding CodeQL and Trivy SARIF reports to ensure no new actionable alerts have appeared since the last scan.
- Consider adding smoke tests that exercise the API with OutputCaching disabled to guard against regressions like the one fixed here.
15 changes: 15 additions & 0 deletions docs/reports/2025-11-maintenance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# November 2025 Maintenance Sweep

## Dependency refresh
- **Azure.Identity** → 1.17.0 ([release notes](https://github.com/Azure/azure-sdk-for-net/releases/tag/Azure.Identity_1.17.0))
- **Microsoft.AspNetCore.OutputCaching.StackExchangeRedis** → 8.0.16 ([release notes](https://github.com/dotnet/aspnetcore/releases/tag/v8.0.16))
- **Entity Framework Core toolchain/tests** → 8.0.11 (`Microsoft.EntityFrameworkCore.Design`, `Microsoft.EntityFrameworkCore.InMemory`, `Microsoft.AspNetCore.Mvc.Testing`, `Microsoft.Data.Sqlite`) ([release notes](https://github.com/dotnet/efcore/releases/tag/v8.0.11))

## Security & platform hygiene
- Ensure NetEscapades security headers execute first in the request pipeline for consistent coverage.
- Tighten the CORS allow-list policy to always emit `Vary: Origin` and reject wildcard origins when credentials are allowed.
- Add `DatabaseOptions` with options validation so production/startup fails fast if the selected provider lacks a connection string.

## Follow-up considerations
- Monitor Azure SDK release cadence for identity/authentication improvements (next review February 2026).
- Consider onboarding `dotnet outdated` tool manifest to capture advisory metadata in future sweeps.
8 changes: 4 additions & 4 deletions src/App2.Api/App2.Api.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,14 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.Extensions.AspNetCore.Configuration.Secrets" Version="1.4.0" />
<PackageReference Include="Azure.Identity" Version="1.11.4" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.6">
<PackageReference Include="Azure.Identity" Version="1.17.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.11">
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.AspNetCore.OutputCaching.StackExchangeRedis" Version="8.0.0" />
<PackageReference Include="Microsoft.AspNetCore.OutputCaching.StackExchangeRedis" Version="8.0.16" />
<PackageReference Include="Microsoft.Identity.Web" Version="3.8.2" />
<PackageReference Include="NetEscapades.AspNetCore.SecurityHeaders" Version="0.21.0" />
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="8.0.6" />
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="8.0.11" />
<PackageReference Include="Serilog.AspNetCore" Version="8.0.1" />
<PackageReference Include="Serilog.Sinks.Console" Version="5.0.1" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
Expand Down
38 changes: 29 additions & 9 deletions src/App2.Api/Endpoints/Todos/TodosEndpointGroup.cs
Original file line number Diff line number Diff line change
@@ -1,25 +1,30 @@
using System;
using App2.Api.Constants;
using App2.Application.Features.Todos.Commands;
using App2.Application.Features.Todos.Dtos;
using App2.Application.Features.Todos.Queries;
using MediatR;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.OutputCaching;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;

namespace App2.Api.Endpoints.Todos;

public static class TodosEndpointGroup
{
public static IEndpointRouteBuilder MapTodosEndpoints(this IEndpointRouteBuilder builder, bool requireAuthorization)
public static IEndpointRouteBuilder MapTodosEndpoints(this IEndpointRouteBuilder builder, bool requireAuthorization, bool enableOutputCache)
{
var group = builder.MapGroup(AppConstants.Routes.TodosBase)
.WithTags("Todos");

var getEndpoint = group.MapGet("/", GetTodosAsync)
.RequireRateLimiting(AppConstants.RateLimiting.FixedPolicy)
.CacheOutput(AppConstants.Cache.TodosPolicy)
.Produces<IReadOnlyList<TodoDto>>(StatusCodes.Status200OK);

getEndpoint = ConfigureCaching(getEndpoint, enableOutputCache);

if (requireAuthorization)
{
getEndpoint.RequireAuthorization("Todo.Read");
Expand All @@ -45,10 +50,11 @@ public static IEndpointRouteBuilder MapTodosEndpoints(this IEndpointRouteBuilder

var getByIdEndpoint = group.MapGet("/{id:int}", GetTodoByIdAsync)
.RequireRateLimiting(AppConstants.RateLimiting.FixedPolicy)
.CacheOutput(AppConstants.Cache.TodosPolicy)
.Produces<TodoDto>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound);

getByIdEndpoint = ConfigureCaching(getByIdEndpoint, enableOutputCache);

if (requireAuthorization)
{
getByIdEndpoint.RequireAuthorization("Todo.Read");
Expand Down Expand Up @@ -99,11 +105,11 @@ private static async Task<Ok<IReadOnlyList<TodoDto>>> GetTodosAsync(IMediator me
private static async Task<Created<TodoDto>> CreateTodoAsync(
CreateTodoCommand command,
IMediator mediator,
IOutputCacheStore cache,
IServiceProvider services,
CancellationToken cancellationToken)
{
var created = await mediator.Send(command, cancellationToken);
await cache.EvictByTagAsync(AppConstants.Cache.TodosTag, cancellationToken);
await EvictTodosCacheAsync(services, cancellationToken);
return TypedResults.Created($"{AppConstants.Routes.TodosBase}/{created.Id}", created);
}

Expand All @@ -122,7 +128,7 @@ private static async Task<Results<Ok<TodoDto>, NotFound>> UpdateTodoAsync(
int id,
UpdateTodoCommand command,
IMediator mediator,
IOutputCacheStore cache,
IServiceProvider services,
CancellationToken cancellationToken)
{
// Ensure the ID from the route matches the command
Expand All @@ -134,14 +140,14 @@ private static async Task<Results<Ok<TodoDto>, NotFound>> UpdateTodoAsync(
return TypedResults.NotFound();
}

await cache.EvictByTagAsync(AppConstants.Cache.TodosTag, cancellationToken);
await EvictTodosCacheAsync(services, cancellationToken);
return TypedResults.Ok(updated);
}

private static async Task<Results<NoContent, NotFound>> DeleteTodoAsync(
int id,
IMediator mediator,
IOutputCacheStore cache,
IServiceProvider services,
CancellationToken cancellationToken)
{
var deleted = await mediator.Send(new DeleteTodoCommand(id), cancellationToken);
Expand All @@ -150,7 +156,21 @@ private static async Task<Results<NoContent, NotFound>> DeleteTodoAsync(
return TypedResults.NotFound();
}

await cache.EvictByTagAsync(AppConstants.Cache.TodosTag, cancellationToken);
await EvictTodosCacheAsync(services, cancellationToken);
return TypedResults.NoContent();
}

private static RouteHandlerBuilder ConfigureCaching(RouteHandlerBuilder builder, bool enableOutputCache)
=> enableOutputCache ? builder.CacheOutput(AppConstants.Cache.TodosPolicy) : builder;

private static async Task EvictTodosCacheAsync(IServiceProvider services, CancellationToken cancellationToken)
{
var cache = services.GetService<IOutputCacheStore>();
if (cache is null)
{
return;
}

await cache.EvictByTagAsync(AppConstants.Cache.TodosTag, cancellationToken);
}
}
20 changes: 20 additions & 0 deletions src/App2.Api/Extensions/CorsExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
using System;
using System.Linq;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;

namespace App2.Api.Extensions;

public static class CorsExtensions
Expand Down Expand Up @@ -34,4 +41,17 @@ public static IServiceCollection AddCorsAllowList(this IServiceCollection servic

return services;
}

public static IApplicationBuilder UseCorsVaryHeader(this IApplicationBuilder app)
{
return app.Use(async (context, next) =>
{
await next();

if (context.Response.Headers.ContainsKey("Access-Control-Allow-Origin"))
{
context.Response.Headers.AppendCommaSeparatedValues("Vary", "Origin");
}
});
}
}
28 changes: 23 additions & 5 deletions src/App2.Api/Extensions/DataExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,28 +1,46 @@
using System;
using App2.Api.Options;
using App2.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;

namespace App2.Api.Extensions;

public static class DataExtensions
{
public static IServiceCollection AddAppData(this IServiceCollection services, IConfiguration configuration, IHostEnvironment environment)
public static IServiceCollection AddAppData(this IServiceCollection services, IHostEnvironment environment)
{
services.AddDbContext<AppDbContext>((serviceProvider, options) =>
{
var provider = configuration["Data:Provider"] ?? "Sqlite";
var connectionStrings = configuration.GetSection("Data:ConnectionStrings");
var databaseOptions = serviceProvider.GetRequiredService<IOptions<DatabaseOptions>>().Value;
var provider = databaseOptions.Provider ?? "Sqlite";

if (provider.Equals("Postgres", StringComparison.OrdinalIgnoreCase))
{
var connection = connectionStrings["Postgres"] ?? "Host=localhost;Database=app2;Username=postgres;Password=postgres";
var connection = databaseOptions.ConnectionStrings.Postgres;
if (string.IsNullOrWhiteSpace(connection) && environment.IsDevelopment())
{
connection = "Host=localhost;Database=app2;Username=postgres;Password=postgres";
}

if (string.IsNullOrWhiteSpace(connection))
{
throw new InvalidOperationException("Data:ConnectionStrings:Postgres must be configured when using the Postgres provider.");
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Database Validation Mismatch Across Environments

The DatabaseOptions validation in Program.cs doesn't fully align with the connection string resolution in DataExtensions.cs. For Postgres, validation passes in Testing environments without a configured connection string, but the application fails at runtime. Conversely, for SQLite, validation fails in non-development/testing environments even though the runtime provides an unconditional default connection string.

Additional Locations (1)

Fix in Cursor Fix in Web

options.UseNpgsql(connection, npgsql =>
{
npgsql.EnableRetryOnFailure();
});
}
else
{
var path = configuration["Data:ConnectionStrings:Sqlite"] ?? "Data Source=app2.db";
var path = databaseOptions.ConnectionStrings.Sqlite;
if (string.IsNullOrWhiteSpace(path))
{
path = "Data Source=app2.db";
}
options.UseSqlite(path);
}

Expand Down
28 changes: 28 additions & 0 deletions src/App2.Api/Options/DatabaseOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
using System;
using System.ComponentModel.DataAnnotations;

namespace App2.Api.Options;

public sealed class DatabaseOptions
{
public const string SectionName = "Data";

[Required]
[RegularExpression("Sqlite|Postgres", ErrorMessage = "Data:Provider must be either 'Sqlite' or 'Postgres'.")]
public string Provider { get; set; } = "Sqlite";

[Required]
public ConnectionStringsOptions ConnectionStrings { get; set; } = new();

public sealed class ConnectionStringsOptions
{
public string? Sqlite { get; set; }

public string? Postgres { get; set; }
}

public string? GetConnectionStringForProvider()
=> Provider.Equals("Postgres", StringComparison.OrdinalIgnoreCase)
? ConnectionStrings.Postgres
: ConnectionStrings.Sqlite;
}
31 changes: 22 additions & 9 deletions src/App2.Api/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using System.Reflection;
using App2.Api.Endpoints.Todos;
using App2.Api.Extensions;
using App2.Api.Options;
using App2.Application.Common.Behaviors;
using App2.Application.Features.Todos.Commands;
using App2.Application.Features.Todos.Validators;
Expand Down Expand Up @@ -70,8 +71,19 @@
builder.Services.AddScoped<ITodoRepository, TodoRepository>();

var features = builder.Configuration.GetSection("Features");

builder.Services.AddAppData(builder.Configuration, builder.Environment);
var outputCachingEnabled = features.GetValue("OutputCaching", false);

builder.Services.AddOptions<DatabaseOptions>()
.Bind(builder.Configuration.GetSection(DatabaseOptions.SectionName))
.ValidateDataAnnotations()
.Validate(
options => builder.Environment.IsDevelopment()
|| builder.Environment.IsEnvironment("Testing")
|| !string.IsNullOrWhiteSpace(options.GetConnectionStringForProvider()),
"A database connection string must be configured for the selected provider in non-development environments.")
.ValidateOnStart();

builder.Services.AddAppData(builder.Environment);
builder.Services.AddOutputCacheWithOptionalRedis(builder.Configuration);

var authenticationEnabled = false;
Expand Down Expand Up @@ -112,18 +124,19 @@

var app = builder.Build();

app.UseSerilogRequestLogging();
app.UseExceptionHandler();
app.UseStatusCodePages();
app.UseHttpsRedirection();

if (features.GetValue("SecurityHeaders", false))
{
app.UseSecurityHeaders(builder.Configuration);
}

app.UseSerilogRequestLogging();
app.UseExceptionHandler();
app.UseStatusCodePages();
app.UseHttpsRedirection();

if (features.GetValue("CORS", false))
{
app.UseCorsVaryHeader();
app.UseCors("AllowedOrigins");
}

Expand All @@ -138,7 +151,7 @@
app.UseRateLimiter();
}

if (features.GetValue("OutputCaching", false))
if (outputCachingEnabled)
{
app.UseOutputCache();
}
Expand All @@ -156,7 +169,7 @@
}));

app.MapHealthEndpoints();
app.MapTodosEndpoints(authenticationEnabled);
app.MapTodosEndpoints(authenticationEnabled, outputCachingEnabled);
app.MapControllers();

app.ValidateRequiredConfiguration(builder.Configuration, app.Environment);
Expand Down
Loading
Loading