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
42 changes: 42 additions & 0 deletions .agents/rules/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,45 @@ Opt-in per endpoint with **`.WithIdempotency()`**. Reads the `Idempotency-Key` h
`QuotaEnforcementMiddleware` charges 1 `ApiCalls` unit per request via `CheckAndRecordAsync`; over-limit → 429 + ProblemDetails + `Retry-After`, and sets `HttpContext.Items[QuotaRejected]` so auditing can tag it. Resources: `ApiCalls` (counter), `StorageBytes`, `Users`, `ActiveFeatureFlags` (gauges). Skips health/metrics, unresolved tenants, and the root tenant. **Pipeline:** runs after auth (needs tenant) and after the rate limiter. Inject `TimeProvider` (not `DateTimeOffset.UtcNow`) for any time math here — the subsystem is `TimeProvider`-based.

`IQuotaService`: `CheckAsync` (no mutation), `RecordAsync` (increment), `CheckAndRecordAsync` (atomic — won't increment past the limit). Store: Redis (`RedisQuotaService`) or per-process `InMemoryQuotaService` (dev/test). `NoopQuotaService` when disabled.

## Data Protection keys (`Caching/`, `Persistence/DataProtection/`)

Data Protection encrypts auth cookies, password-reset and confirmation tokens, and antiforgery
tokens. Two settings decide whether that survives.

**`DataProtection:ApplicationName`** is what keys are isolated by, so it MUST be unique per
application. It is read from configuration, never hard-coded: the framework also ships as compiled
`FSH.Framework.*` packages, where the template's token substitution cannot reach a literal, so a
constant would make every project on those packages share one key ring — and two of them against
the same store could decrypt each other's cookies and tokens. `appsettings.json` is scaffolded
source, so the value there is renamed per project in both distribution modes. Unconfigured, it
falls back to the entry assembly name, which errs towards isolation.

**`DataProtection:Store`** is `Redis` (default) or `Database`.

| | Redis | Database |
|---|---|---|
| Wired in | `AddHeroCaching` | `AddHeroPlatform` |
| Needs | a configured Redis | `DataProtectionKeysDbContext` + its migrations |
| Choose when | Redis is durable and shared by every host | hosts do not reliably share Redis, or Redis is a cache with eviction |

Pick `Database` when the DbMigrator is run standalone — outside the AppHost wiring that injects a
Redis connection string — since anything its seed encrypts otherwise becomes undecryptable by the
API (`CryptographicException: key {guid} not found in the key ring`). Redis eviction has the same
effect on a live system: losing a key takes every session and pending reset token with it.

The DbMigrator additionally creates the key table **before** it starts its host
(`DataProtectionSchema.EnsureAsync`). Data Protection resolves its key ring eagerly during
`StartAsync`, long before the migrator's own Step 0/1/2 flow, so the `IDbInitializer` alone is too
late there: the first run against an empty database logs a query failure with a stack trace and —
worse — does not fail, because a key created while the table is missing cannot be persisted, and
anything encrypted in that window is undecryptable afterwards. The initializer still covers the API
and the test harness, which migrate before they serve.

`DataProtectionKeysDbContext` is a plain `DbContext`, not `BaseDbContext` — keys are global
framework infrastructure, not tenant data. It registers a `DataProtectionKeysDbInitializer` like
every other framework context, which is what makes the table appear in the API, the DbMigrator and
the integration-test harness alike. **Wiring the context without its `IDbInitializer` is the
failure mode to avoid**: the table then only exists wherever someone migrated it by hand, and every
flow that protects a payload fails with `Invalid object name 'DataProtectionKeys'` everywhere else.

19 changes: 16 additions & 3 deletions src/BuildingBlocks/Caching/Extensions.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using FSH.Framework.Core.DataProtection;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.Extensions.Caching.Hybrid;
using Microsoft.Extensions.Configuration;
Expand Down Expand Up @@ -60,9 +61,21 @@

// Persist Data Protection keys (auth cookies, reset/confirmation tokens, antiforgery) to
// Redis so multi-instance hosts share a key ring and tokens survive rolling restarts.
services.AddDataProtection()
.PersistKeysToStackExchangeRedis(sharedMultiplexer, "DataProtection-Keys")
.SetApplicationName("FSH.Starter");
// Skipped when the database store is selected: AddHeroPlatform wires the key ring to
// DataProtectionKeysDbContext instead. Configuring both would leave the last
// PersistKeysTo call silently deciding where keys actually land.
//
// The application name comes from configuration (see #1372): a literal here would ship
// inside the compiled FSH.Framework.Caching package, where the template cannot rename
// it, making every project on that package share one key ring.
if (!DataProtectionStores.UsesDatabase(configuration[DataProtectionStores.ConfigurationKey]))
{
services.AddDataProtection()
.PersistKeysToStackExchangeRedis(sharedMultiplexer, "DataProtection-Keys")
.SetApplicationName(
DataProtectionApplicationName.Resolve(

Check failure on line 76 in src/BuildingBlocks/Caching/Extensions.cs

View workflow job for this annotation

GitHub Actions / DbMigrator Container Smoke

The name 'DataProtectionApplicationName' does not exist in the current context
configuration[DataProtectionApplicationName.ConfigurationKey]));

Check failure on line 77 in src/BuildingBlocks/Caching/Extensions.cs

View workflow job for this annotation

GitHub Actions / DbMigrator Container Smoke

The name 'DataProtectionApplicationName' does not exist in the current context
}
}

// HybridCache auto-composes with whatever IDistributedCache is registered above.
Expand Down
24 changes: 24 additions & 0 deletions src/BuildingBlocks/Core/DataProtection/DataProtectionStores.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
namespace FSH.Framework.Core.DataProtection;

/// <summary>
/// Where ASP.NET Core Data Protection keys are persisted, selected by
/// <c>DataProtection:Store</c> in configuration.
///
/// Lives in Core because both Caching (which owns the Redis store) and Web (which owns the
/// database store) have to agree on the value, and Caching cannot reference Persistence.
/// </summary>
public static class DataProtectionStores
{
/// <summary>Configuration key selecting the store.</summary>
public const string ConfigurationKey = "DataProtection:Store";

/// <summary>Redis, when configured. The default, and what the framework has always used.</summary>
public const string Redis = "REDIS";

/// <summary>The application database, via the framework's Data Protection keys context.</summary>
public const string Database = "DATABASE";

/// <summary>True when configuration selects the database store.</summary>
public static bool UsesDatabase(string? configuredValue) =>
string.Equals(configuredValue?.Trim(), Database, StringComparison.OrdinalIgnoreCase);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
using Microsoft.AspNetCore.DataProtection.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;

namespace FSH.Framework.Persistence.DataProtection;

/// <summary>
/// Persists ASP.NET Core Data Protection keys to the application database.
/// </summary>
/// <remarks>
/// <para>
/// The alternative store is Redis, which stays the default. Database-backed keys matter when the
/// hosts that protect and unprotect data do not reliably share a Redis instance: the DbMigrator is
/// normally run standalone, outside the AppHost wiring that injects a Redis connection string, so
/// anything its seed encrypts can become permanently undecryptable by the API — a
/// <c>CryptographicException: key {guid} not found in the key ring</c> that no amount of pinning
/// the application name will fix, because the two hosts simply have different key rings. Redis
/// eviction has the same effect on a live system: keys stored in a cache can be evicted, taking
/// every session and pending reset token with them.
/// </para>
/// <para>
/// Deliberately a plain <see cref="DbContext"/> rather than <c>BaseDbContext</c>: Data Protection
/// keys are global framework infrastructure, not tenant data, so they are neither tenant-filtered
/// nor duplicated into a tenant's dedicated database.
/// </para>
/// <para>
/// Provider-neutral. It is wired through <c>AddHeroDbContext</c> like every other context, so it
/// follows <c>DatabaseOptions:Provider</c>, and each provider's migrations project carries its own
/// <c>DataProtection/</c> folder for it.
/// </para>
/// </remarks>
public sealed class DataProtectionKeysDbContext(DbContextOptions<DataProtectionKeysDbContext> options)
: DbContext(options), IDataProtectionKeyContext
{
public DbSet<DataProtectionKey> DataProtectionKeys => Set<DataProtectionKey>();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;

namespace FSH.Framework.Persistence.DataProtection;

/// <summary>
/// Migrates the Data Protection key schema.
/// </summary>
/// <remarks>
/// Registered as an <see cref="IDbInitializer"/> so the table is created by whatever brings the
/// schema up — the API host, the DbMigrator, and the integration-test harness alike. Wiring the
/// context without an initializer is the failure mode this exists to prevent: the table then only
/// appears wherever someone remembered to migrate it by hand, and every flow that protects a
/// payload (registration, password reset, two-factor) fails with a
/// <c>CryptographicException</c> everywhere else.
///
/// The context is not tenant-aware, so this targets the root connection on every pass; after the
/// first it is a no-op, which is why running inside the per-tenant loop is harmless.
/// </remarks>
public sealed partial class DataProtectionKeysDbInitializer : IDbInitializer
{
private readonly DataProtectionKeysDbContext _context;
private readonly ILogger<DataProtectionKeysDbInitializer> _logger;

public DataProtectionKeysDbInitializer(
DataProtectionKeysDbContext context,
ILogger<DataProtectionKeysDbInitializer> logger)
{
_context = context;
_logger = logger;
}

public async Task MigrateAsync(CancellationToken cancellationToken)
{
if ((await _context.Database.GetPendingMigrationsAsync(cancellationToken).ConfigureAwait(false)).Any())
{
await _context.Database.MigrateAsync(cancellationToken).ConfigureAwait(false);
LogMigrated();
}
}

public Task SeedAsync(CancellationToken cancellationToken) => Task.CompletedTask;

[LoggerMessage(Level = LogLevel.Information, Message = "applied database migrations for the Data Protection key schema")]
private partial void LogMigrated();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
using Microsoft.EntityFrameworkCore;

namespace FSH.Framework.Persistence.DataProtection;

/// <summary>
/// Creates the Data Protection key table before anything can read the key ring.
/// </summary>
/// <remarks>
/// <para>
/// <see cref="DataProtectionKeysDbInitializer"/> covers hosts that migrate before they serve — the
/// API and the integration-test harness. It is not enough for the DbMigrator, which builds and
/// STARTS a full host before running its own migration flow: Data Protection resolves its key ring
/// eagerly during that start, queries the table, and logs a failure with a stack trace on every
/// first run against an empty database. Nothing crashes, which is worse than crashing — a key
/// created while the table is missing cannot be persisted, so anything encrypted in that window is
/// undecryptable afterwards.
/// </para>
/// <para>
/// Deliberately standalone: it builds its own context rather than resolving one from the host,
/// because the whole point is to run before the host exists.
/// </para>
/// </remarks>
public static class DataProtectionSchema
{
/// <summary>Applies pending migrations for the Data Protection key schema.</summary>
public static async Task EnsureAsync(
string dbProvider,
string connectionString,
string migrationsAssembly,
bool isDevelopment,
CancellationToken cancellationToken = default)
{
var options = new DbContextOptionsBuilder<DataProtectionKeysDbContext>();

// ConfigureHeroDatabase rather than a bare UseNpgsql/UseSqlServer: it is the one call that
// picks the provider, points at that provider's migrations assembly and applies the
// provider-specific options, so this follows DatabaseOptions:Provider like every context.
options.ConfigureHeroDatabase(dbProvider, connectionString, migrationsAssembly, isDevelopment);

await using var context = new DataProtectionKeysDbContext(options.Options);

if ((await context.Database.GetPendingMigrationsAsync(cancellationToken).ConfigureAwait(false)).Any())
{
await context.Database.MigrateAsync(cancellationToken).ConfigureAwait(false);
}
}
}
1 change: 1 addition & 0 deletions src/BuildingBlocks/Persistence/Persistence.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.DataProtection.EntityFrameworkCore" />
<PackageReference Include="Finbuckle.MultiTenant.EntityFrameworkCore" />
<PackageReference Include="Microsoft.EntityFrameworkCore" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Abstractions" />
Expand Down
32 changes: 29 additions & 3 deletions src/BuildingBlocks/Web/Extensions.cs
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
using FSH.Framework.Caching;
using FSH.Framework.Core.DataProtection;
using FSH.Framework.Jobs;
using FSH.Framework.Mailing;
using FSH.Framework.Persistence;
using FSH.Framework.Persistence.DataProtection;
using FSH.Framework.Quota;
using FSH.Framework.Shared.Constants;
using FSH.Framework.Web.Auth;
using FSH.Framework.Web.Cors;
using FSH.Framework.Web.Exceptions;
using FSH.Framework.Web.FeatureFlags;
using FSH.Framework.Web.Idempotency;
using FSH.Framework.Web.Sse;
using FSH.Framework.Web.Health;
using FSH.Framework.Web.Idempotency;
using FSH.Framework.Web.Mediator.Behaviors;
using FSH.Framework.Web.Modules;
using FSH.Framework.Web.Observability.Logging.Serilog;
Expand All @@ -20,15 +21,19 @@
using FSH.Framework.Web.RateLimiting;
using FSH.Framework.Web.Realtime;
using FSH.Framework.Web.Security;
using FSH.Framework.Web.Sse;
using FSH.Framework.Web.Versioning;
using Mediator;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.DataProtection.EntityFrameworkCore;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.ResponseCompression;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Hosting;
using Mediator;

namespace FSH.Framework.Web;

Expand Down Expand Up @@ -104,6 +109,27 @@ public static IHostApplicationBuilder AddHeroPlatform(this IHostApplicationBuild
}
}

// Data Protection keys in the database rather than Redis. Wired here because this is the
// only place that sees both Caching (which owns the Redis store) and Persistence (which
// owns the context); Caching skips its own wiring when this store is selected.
//
// Worth choosing when the hosts that protect and unprotect data do not reliably share a
// Redis instance - the DbMigrator is normally run standalone, outside the AppHost wiring
// that injects a Redis connection string - or when Redis is a cache with eviction, where
// losing a key evicts every session and pending reset token with it.
if (DataProtectionStores.UsesDatabase(builder.Configuration[DataProtectionStores.ConfigurationKey]))
{
builder.Services.AddHeroDbContext<DataProtectionKeysDbContext>();
builder.Services.TryAddEnumerable(
ServiceDescriptor.Scoped<IDbInitializer, DataProtectionKeysDbInitializer>());

builder.Services.AddDataProtection()
.PersistKeysToDbContext<DataProtectionKeysDbContext>()
.SetApplicationName(
DataProtectionApplicationName.Resolve(
builder.Configuration[DataProtectionApplicationName.ConfigurationKey]));
}

if (options.EnableFeatureFlags)
{
builder.Services.AddHeroFeatureFlags(builder.Configuration);
Expand Down
1 change: 1 addition & 0 deletions src/Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.8" />
<PackageVersion Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="10.0.8" />
<PackageVersion Include="Microsoft.AspNetCore.DataProtection.StackExchangeRedis" Version="10.0.8" />
<PackageVersion Include="Microsoft.AspNetCore.DataProtection.EntityFrameworkCore" Version="10.0.8" />
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="10.0.8" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.8" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.8" />
Expand Down
7 changes: 7 additions & 0 deletions src/Host/FSH.Starter.Api/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,13 @@
"OriginOptions": {
"OriginUrl": "https://localhost:7030"
},
"DataProtection": {
// "Redis" (default) or "Database". Choose Database when the hosts that protect and unprotect
// data do not reliably share a Redis instance - the DbMigrator is normally run standalone,
// outside the AppHost wiring that injects a Redis connection string - or when Redis is a cache
// with eviction, where losing a key takes every session and pending reset token with it.
"Store": "Redis"
},
"CachingOptions": {
"Redis": ""
},
Expand Down
32 changes: 32 additions & 0 deletions src/Host/FSH.Starter.DbMigrator/Program.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
using System.Globalization;
using System.Reflection;
using FSH.Framework.Core.DataProtection;
using FSH.Framework.Eventing;
using FSH.Framework.Persistence.DataProtection;
using FSH.Framework.Shared.Multitenancy;
using FSH.Framework.Web;
using FSH.Framework.Web.Modules;
Expand Down Expand Up @@ -161,6 +163,36 @@ await Console.Error.WriteLineAsync(
// the DI graph is satisfied; the verb dispatch below decides whether to call it.
builder.Services.AddScoped<DemoSeeder>();

// The Data Protection key table has to exist BEFORE the host starts, when keys live in the
// database. Data Protection resolves its key ring eagerly during StartAsync, well before this
// file's own Step 0/1/2 flow runs, so leaving it to the IDbInitializer means the first run against
// an empty database logs a query failure with a stack trace - and, worse, does not fail: a key
// created while the table is missing cannot be persisted, so anything encrypted in that window is
// undecryptable afterwards. The initializer still covers the API and the test harness, which
// migrate before they serve.
//
// This waits for the database itself, because Step 0's wait is also after StartAsync.
if (DataProtectionStores.UsesDatabase(builder.Configuration[DataProtectionStores.ConfigurationKey]))
{
using var bootstrapLoggerFactory = LoggerFactory.Create(b => b.AddConsole());
var bootstrapLogger = bootstrapLoggerFactory.CreateLogger("DataProtectionSchema");

var bootstrapProvider = builder.Configuration["DatabaseOptions:Provider"] ?? DbProviders.PostgreSQL;
var bootstrapConnectionString = builder.Configuration["DatabaseOptions:ConnectionString"]
?? throw new InvalidOperationException("DatabaseOptions:ConnectionString is not configured.");

await MigratorLockFactory.Create(bootstrapProvider)
.WaitForDatabaseAsync(bootstrapConnectionString, bootstrapLogger, CancellationToken.None)
.ConfigureAwait(false);

await DataProtectionSchema.EnsureAsync(
bootstrapProvider,
bootstrapConnectionString,
builder.Configuration["DatabaseOptions:MigrationsAssembly"]
?? throw new InvalidOperationException("DatabaseOptions:MigrationsAssembly is not configured."),
builder.Environment.IsDevelopment()).ConfigureAwait(false);
}

using var host = builder.Build();
var logger = host.Services.GetRequiredService<ILogger<MigratorCommand>>();

Expand Down
Loading
Loading