From 2fb51b2dd13bc26ecfbd7ed428f195c6ad7cac2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20G=C3=B6pel?= Date: Sat, 4 Jul 2026 22:44:27 +0800 Subject: [PATCH] fix: don't allow runtime schema drops outside Development MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AutoCreate.All lets Marten drop and rewrite schema objects at runtime to match code, forcing the app's DB role to hold DDL rights and risking data loss on a code/database mismatch. Default AutoCreateSchemaObjects by environment instead: keep All for the Development inner loop, but use CreateOrUpdate (additive only, never drops) everywhere else. Add an AppFoundationOptions.SchemaCreation knob so a host can override — e.g. AutoCreate.None for a least-privilege role with schema provisioned out-of-band. Closes #53 --- .../AppFoundationOptions.cs | 17 +++++ .../Initialization.cs | 14 +++- .../AddAppFoundationSchemaCreationTests.cs | 72 +++++++++++++++++++ 3 files changed, 102 insertions(+), 1 deletion(-) create mode 100644 tests/AndreGoepel.AppFoundation.Tests/Hosting/AddAppFoundationSchemaCreationTests.cs diff --git a/src/AndreGoepel.AppFoundation.Hosting/AppFoundationOptions.cs b/src/AndreGoepel.AppFoundation.Hosting/AppFoundationOptions.cs index f8ab257..1850e8a 100644 --- a/src/AndreGoepel.AppFoundation.Hosting/AppFoundationOptions.cs +++ b/src/AndreGoepel.AppFoundation.Hosting/AppFoundationOptions.cs @@ -1,3 +1,4 @@ +using JasperFx; using Microsoft.AspNetCore.DataProtection; using Wolverine; @@ -54,4 +55,20 @@ public sealed class AppFoundationOptions /// options.ConfigureWolverine = w => w.Discovery.IncludeAssembly(typeof(SomeHandler).Assembly). /// public Action? ConfigureWolverine { get; set; } + + /// + /// How Marten manages the database schema. When null (the default) the + /// foundation selects a safe mode by environment: in + /// Development (fast inner loop; permits destructive rebuilds) and + /// everywhere else (additive only — never + /// drops or rewrites existing objects, so a code/database mismatch cannot destroy + /// data at runtime). + /// + /// Set explicitly to take control — most notably for a + /// least-privilege deployment where the schema is provisioned out-of-band (a + /// migration job / db-apply) and the application runs with a database role + /// that has no DDL rights. + /// + /// + public AutoCreate? SchemaCreation { get; set; } } diff --git a/src/AndreGoepel.AppFoundation.Hosting/Initialization.cs b/src/AndreGoepel.AppFoundation.Hosting/Initialization.cs index e0a8fb3..b144624 100644 --- a/src/AndreGoepel.AppFoundation.Hosting/Initialization.cs +++ b/src/AndreGoepel.AppFoundation.Hosting/Initialization.cs @@ -64,7 +64,19 @@ public static WebApplicationBuilder AddAppFoundation( marten.Connection(connectionString); marten.InitializeIdentity(); - marten.AutoCreateSchemaObjects = AutoCreate.All; + + // Never let the running app drop/rewrite schema to match code: default + // to additive-only (CreateOrUpdate) outside Development, keeping the + // permissive All only for the local inner loop. A host can override — + // e.g. AutoCreate.None for a least-privilege role with schema applied + // out-of-band (#53). + marten.AutoCreateSchemaObjects = + options.SchemaCreation + ?? ( + builder.Environment.IsDevelopment() + ? AutoCreate.All + : AutoCreate.CreateOrUpdate + ); // The alias (and thus the table name) is part of the storage // contract — hosts that persisted key ring entries with an diff --git a/tests/AndreGoepel.AppFoundation.Tests/Hosting/AddAppFoundationSchemaCreationTests.cs b/tests/AndreGoepel.AppFoundation.Tests/Hosting/AddAppFoundationSchemaCreationTests.cs new file mode 100644 index 0000000..6097bef --- /dev/null +++ b/tests/AndreGoepel.AppFoundation.Tests/Hosting/AddAppFoundationSchemaCreationTests.cs @@ -0,0 +1,72 @@ +using AndreGoepel.AppFoundation.Hosting; +using JasperFx; +using Marten; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace AndreGoepel.AppFoundation.Tests.Hosting; + +public class AddAppFoundationSchemaCreationTests +{ + [Fact] + public void AddAppFoundation_NonDevelopmentEnvironment_UsesCreateOrUpdate() + { + // Arrange + var builder = CreateBuilder("Production"); + + // Act + builder.AddAppFoundation(); + + // Assert + Assert.Equal(AutoCreate.CreateOrUpdate, ResolveAutoCreate(builder)); + } + + [Fact] + public void AddAppFoundation_DevelopmentEnvironment_UsesAll() + { + // Arrange + var builder = CreateBuilder("Development"); + + // Act + builder.AddAppFoundation(); + + // Assert + Assert.Equal(AutoCreate.All, ResolveAutoCreate(builder)); + } + + [Fact] + public void AddAppFoundation_ExplicitSchemaCreation_OverridesEnvironmentDefault() + { + // Arrange — Development would otherwise select All. + var builder = CreateBuilder("Development"); + + // Act + builder.AddAppFoundation(options => options.SchemaCreation = AutoCreate.None); + + // Assert + Assert.Equal(AutoCreate.None, ResolveAutoCreate(builder)); + } + + private static AutoCreate ResolveAutoCreate(WebApplicationBuilder builder) + { + using var provider = builder.Services.BuildServiceProvider(); + var store = provider.GetRequiredService(); + return ((StoreOptions)store.Options).AutoCreateSchemaObjects; + } + + private static WebApplicationBuilder CreateBuilder(string environmentName) + { + var builder = WebApplication.CreateBuilder( + new WebApplicationOptions { EnvironmentName = environmentName } + ); + builder.Configuration.AddInMemoryCollection( + new Dictionary + { + ["ConnectionStrings:appfoundation-database"] = + "Host=localhost;Port=5432;Database=test;Username=u;Password=p", + } + ); + return builder; + } +}