diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5a7adafbe..b9afe232c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -64,6 +64,12 @@ dotnet ef migrations add --project src/FluentCMS.Infrastructure --context Migrations apply automatically when the host starts. Every migration needs a working `Down`, since upgrades must be reversible one version back (OPS-04). +## Units of work and the audit log + +Every operation that changes data runs inside a unit of work and appends the audit event that describes it from inside the same scope, so the two commit together or not at all (AUD-N1, ADR-0005). `IUnitOfWork.BeginAsync()` returns a scope; `CommitAsync()` saves and commits, and disposing a scope without committing rolls everything back, including saves that Identity makes on its own inside it. Inner scopes join the outer transaction, and an inner scope disposed without committing makes the outer commit fail. `IAuditLog.AppendAsync(new NewAuditEvent("entry.updated") { ... })` stamps the current initiator and time itself, and throws outside a unit of work or when no initiator is known, so a change without provenance cannot exist. + +Nothing in the code updates or deletes an audit row. On PostgreSQL the `AuditEvents` migration also revokes `UPDATE`, `DELETE` and `TRUNCATE` from `PUBLIC` and from the role that applies it, which owns the table. A superuser bypasses privilege checks, so run the host, which applies migrations at startup, as an ordinary role that owns the database. SQLite has no roles; there the code is the guarantee. + ## Extension points Every pluggable part of the product is an interface in `FluentCMS.Core` marked `[ExtensionPoint]`. Implementations register through dependency injection, either explicitly with `AddFluentCmsExtension()` or by discovery with `AddFluentCmsExtensions(assembly)`, which picks up every concrete class implementing an extension-point interface in that assembly. Both paths produce the same `IExtensionCatalog` entry and the same `IEnumerable` resolution, so built-ins under `FluentCMS.Core.Builtin` and future plugins look identical to the rest of the product. Each interface is documented where it is defined, together with the conformance tests an implementation must pass. diff --git a/src/FluentCMS.Core/Audit/AuditEvent.cs b/src/FluentCMS.Core/Audit/AuditEvent.cs new file mode 100644 index 000000000..11c69e70f --- /dev/null +++ b/src/FluentCMS.Core/Audit/AuditEvent.cs @@ -0,0 +1,63 @@ +using FluentCMS.Core.Identity; + +namespace FluentCMS.Core.Audit; + +// One record of an operation, appended and never edited: who acted, with which model and prompt, from which sources, in which change set (AUD-01). +public sealed record AuditEvent +{ + public required Guid Id + { + get; + init => field = value != Guid.Empty ? value : throw new ArgumentException("An audit event needs an id.", nameof(Id)); + } + + public required DateTimeOffset Timestamp { get; init; } + + public required Initiator Initiator + { + get; + init => field = value ?? throw new ArgumentNullException(nameof(Initiator)); + } + + // What happened, as a dotted lower-case name such as "user.created". + public required string Operation + { + get; + init + { + ArgumentException.ThrowIfNullOrWhiteSpace(value, nameof(Operation)); + field = value; + } + } + + // What it happened to; null until an epic defines the item kind. + public string? SubjectType { get; init; } + + public Guid? SubjectId { get; init; } + + public string? Model { get; init; } + + public string? PromptHash { get; init; } + + public IReadOnlyList Sources + { + get; + init + { + ArgumentNullException.ThrowIfNull(value, nameof(Sources)); + if (value.Any(string.IsNullOrWhiteSpace)) + { + throw new ArgumentException("A source cannot be blank.", nameof(Sources)); + } + + field = value; + } + } = []; + + public Guid? ChangeSetId { get; init; } + + public Guid? PreviousVersionId { get; init; } + + // The credential an agent acted under; null when a person acted (AUD-01, SEC-01). + public Guid? AgentCredentialId => (Initiator as AgentInitiator)?.CredentialId; +} diff --git a/src/FluentCMS.Core/Audit/IAuditLog.cs b/src/FluentCMS.Core/Audit/IAuditLog.cs new file mode 100644 index 000000000..b47b54e85 --- /dev/null +++ b/src/FluentCMS.Core/Audit/IAuditLog.cs @@ -0,0 +1,8 @@ +namespace FluentCMS.Core.Audit; + +public interface IAuditLog +{ + // Appends inside the current unit of work, stamping the current initiator and time itself, so a caller cannot record someone else as the actor. + // Throws when no unit of work is active or no initiator is known: a change without provenance cannot exist (AUD-N1). + Task AppendAsync(NewAuditEvent entry, CancellationToken cancellationToken = default); +} diff --git a/src/FluentCMS.Core/Audit/NewAuditEvent.cs b/src/FluentCMS.Core/Audit/NewAuditEvent.cs new file mode 100644 index 000000000..80f544f9e --- /dev/null +++ b/src/FluentCMS.Core/Audit/NewAuditEvent.cs @@ -0,0 +1,19 @@ +namespace FluentCMS.Core.Audit; + +// The part of an audit event a service supplies; the log adds the id, the time and the initiator. +public sealed record NewAuditEvent(string Operation) +{ + public string? SubjectType { get; init; } + + public Guid? SubjectId { get; init; } + + public string? Model { get; init; } + + public string? PromptHash { get; init; } + + public IReadOnlyList Sources { get; init; } = []; + + public Guid? ChangeSetId { get; init; } + + public Guid? PreviousVersionId { get; init; } +} diff --git a/src/FluentCMS.Core/Persistence/IUnitOfWork.cs b/src/FluentCMS.Core/Persistence/IUnitOfWork.cs new file mode 100644 index 000000000..7eb68673a --- /dev/null +++ b/src/FluentCMS.Core/Persistence/IUnitOfWork.cs @@ -0,0 +1,14 @@ +namespace FluentCMS.Core.Persistence; + +// A change and the audit event that describes it commit together or not at all (AUD-N1, ADR-0005). +public interface IUnitOfWork +{ + // Nested calls join the outer unit of work; only the outermost scope commits the transaction. + Task BeginAsync(CancellationToken cancellationToken = default); +} + +public interface IUnitOfWorkScope : IAsyncDisposable +{ + // Disposing a scope that was not committed rolls back everything, including work committed by inner scopes. + Task CommitAsync(CancellationToken cancellationToken = default); +} diff --git a/src/FluentCMS.Infrastructure/Audit/AuditEventConfiguration.cs b/src/FluentCMS.Infrastructure/Audit/AuditEventConfiguration.cs new file mode 100644 index 000000000..b4cc00a83 --- /dev/null +++ b/src/FluentCMS.Infrastructure/Audit/AuditEventConfiguration.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace FluentCMS.Infrastructure.Audit; + +internal sealed class AuditEventConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder row) + { + // Exactly the agent rows carry a credential, whatever the mapping code does (SEC-01). + row.ToTable("AuditEvents", table => table.HasCheckConstraint( + "CK_AuditEvents_AgentCredentialId", + $"(\"InitiatorKind\" = '{InitiatorKinds.Agent}') = (\"AgentCredentialId\" IS NOT NULL)")); + + row.HasKey(e => e.Id); + row.Property(e => e.Id).ValueGeneratedNever(); + row.Property(e => e.InitiatorKind).HasMaxLength(16).IsRequired(); + row.Property(e => e.InitiatorName).HasMaxLength(256).IsRequired(); + row.Property(e => e.Operation).HasMaxLength(128).IsRequired(); + row.Property(e => e.SubjectType).HasMaxLength(64); + row.Property(e => e.Model).HasMaxLength(128); + row.Property(e => e.PromptHash).HasMaxLength(128); + + row.HasIndex(e => e.Timestamp); + row.HasIndex(e => e.ChangeSetId); + row.HasIndex(e => e.AgentCredentialId); + row.HasIndex(e => new { e.SubjectType, e.SubjectId }); + } +} diff --git a/src/FluentCMS.Infrastructure/Audit/AuditEventRow.cs b/src/FluentCMS.Infrastructure/Audit/AuditEventRow.cs new file mode 100644 index 000000000..7789a584d --- /dev/null +++ b/src/FluentCMS.Infrastructure/Audit/AuditEventRow.cs @@ -0,0 +1,70 @@ +using FluentCMS.Core.Audit; +using FluentCMS.Core.Identity; + +namespace FluentCMS.Infrastructure.Audit; + +// The flat shape of an AuditEvent in the AuditEvents table; rows are inserted and never changed. +public sealed class AuditEventRow +{ + public Guid Id { get; set; } + + public DateTimeOffset Timestamp { get; set; } + + public string InitiatorKind { get; set; } = string.Empty; + + public Guid InitiatorId { get; set; } + + public string InitiatorName { get; set; } = string.Empty; + + public Guid? AgentCredentialId { get; set; } + + public string Operation { get; set; } = string.Empty; + + public string? SubjectType { get; set; } + + public Guid? SubjectId { get; set; } + + public string? Model { get; set; } + + public string? PromptHash { get; set; } + + public List Sources { get; set; } = []; + + public Guid? ChangeSetId { get; set; } + + public Guid? PreviousVersionId { get; set; } + + internal static AuditEventRow From(AuditEvent auditEvent) + { + var (kind, initiatorId) = auditEvent.Initiator switch + { + UserInitiator user => (InitiatorKinds.User, user.UserId), + AgentInitiator agent => (InitiatorKinds.Agent, agent.CredentialId), + _ => throw new NotSupportedException($"The audit log cannot store an initiator of type {auditEvent.Initiator.GetType().Name}."), + }; + + return new AuditEventRow + { + Id = auditEvent.Id, + Timestamp = auditEvent.Timestamp, + InitiatorKind = kind, + InitiatorId = initiatorId, + InitiatorName = auditEvent.Initiator.DisplayName, + AgentCredentialId = auditEvent.AgentCredentialId, + Operation = auditEvent.Operation, + SubjectType = auditEvent.SubjectType, + SubjectId = auditEvent.SubjectId, + Model = auditEvent.Model, + PromptHash = auditEvent.PromptHash, + Sources = [.. auditEvent.Sources], + ChangeSetId = auditEvent.ChangeSetId, + PreviousVersionId = auditEvent.PreviousVersionId, + }; + } +} + +public static class InitiatorKinds +{ + public const string User = "user"; + public const string Agent = "agent"; +} diff --git a/src/FluentCMS.Infrastructure/Audit/AuditLog.cs b/src/FluentCMS.Infrastructure/Audit/AuditLog.cs new file mode 100644 index 000000000..081e3bc16 --- /dev/null +++ b/src/FluentCMS.Infrastructure/Audit/AuditLog.cs @@ -0,0 +1,43 @@ +using FluentCMS.Core.Audit; +using FluentCMS.Core.Identity; +using FluentCMS.Infrastructure.Persistence; + +namespace FluentCMS.Infrastructure.Audit; + +internal sealed class AuditLog(FluentCmsDbContext context, ICurrentInitiator currentInitiator, TimeProvider clock) : IAuditLog +{ + public Task AppendAsync(NewAuditEvent entry, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(entry); + + if (context.Database.CurrentTransaction is null) + { + throw new InvalidOperationException( + "Audit events are appended inside a unit of work, in the same transaction as the change they describe (AUD-N1)."); + } + + var initiator = currentInitiator.Current + ?? throw new InvalidOperationException("No initiator is known for this operation; the audit log records who acted (AUD-01)."); + + var timestamp = clock.GetUtcNow(); + var auditEvent = new AuditEvent + { + Id = Guid.CreateVersion7(timestamp), + Timestamp = timestamp, + Initiator = initiator, + Operation = entry.Operation, + SubjectType = entry.SubjectType, + SubjectId = entry.SubjectId, + Model = entry.Model, + PromptHash = entry.PromptHash, + Sources = entry.Sources, + ChangeSetId = entry.ChangeSetId, + PreviousVersionId = entry.PreviousVersionId, + }; + + // The row reaches the database with the next save inside the transaction, so it commits with the change or not at all. + context.AuditEvents.Add(AuditEventRow.From(auditEvent)); + + return Task.FromResult(auditEvent); + } +} diff --git a/src/FluentCMS.Infrastructure/InfrastructureServiceCollectionExtensions.cs b/src/FluentCMS.Infrastructure/InfrastructureServiceCollectionExtensions.cs index 5f4c499f7..09e5b1fed 100644 --- a/src/FluentCMS.Infrastructure/InfrastructureServiceCollectionExtensions.cs +++ b/src/FluentCMS.Infrastructure/InfrastructureServiceCollectionExtensions.cs @@ -1,4 +1,7 @@ +using FluentCMS.Core.Audit; using FluentCMS.Core.Identity; +using FluentCMS.Core.Persistence; +using FluentCMS.Infrastructure.Audit; using FluentCMS.Infrastructure.Identity; using FluentCMS.Infrastructure.Persistence; using Microsoft.EntityFrameworkCore; @@ -17,6 +20,7 @@ public static IServiceCollection AddFluentCmsInfrastructure(this IServiceCollect AddDatabase(services, configuration.GetSection(DatabaseOptions.SectionName).Get() ?? new DatabaseOptions()); AddIdentity(services); + AddAudit(services); services.AddHostedService(); @@ -40,6 +44,13 @@ private static void AddDatabase(IServiceCollection services, DatabaseOptions dat } } + // The log reads ICurrentInitiator, which each host registers for its own kind of caller. + private static void AddAudit(IServiceCollection services) + { + services.AddScoped(); + services.AddScoped(); + } + private static void AddIdentity(IServiceCollection services) { services.AddIdentityCore(options => options.User.RequireUniqueEmail = true) diff --git a/src/FluentCMS.Infrastructure/Persistence/FluentCmsDbContext.cs b/src/FluentCMS.Infrastructure/Persistence/FluentCmsDbContext.cs index 7d673cef9..a52cf3519 100644 --- a/src/FluentCMS.Infrastructure/Persistence/FluentCmsDbContext.cs +++ b/src/FluentCMS.Infrastructure/Persistence/FluentCmsDbContext.cs @@ -1,3 +1,4 @@ +using FluentCMS.Infrastructure.Audit; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; @@ -7,10 +8,14 @@ namespace FluentCMS.Infrastructure.Persistence; // One model, one provider-specific subclass per database so each keeps its own migrations (ADR-0005). public abstract class FluentCmsDbContext(DbContextOptions options) : IdentityDbContext(options) { + public DbSet AuditEvents => Set(); + protected override void OnModelCreating(ModelBuilder builder) { base.OnModelCreating(builder); + builder.ApplyConfiguration(new AuditEventConfiguration()); + builder.Entity(user => { user.ToTable("Users"); diff --git a/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261017230527_AuditEvents.Designer.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261017230527_AuditEvents.Designer.cs new file mode 100644 index 000000000..38a93b51a --- /dev/null +++ b/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261017230527_AuditEvents.Designer.cs @@ -0,0 +1,355 @@ +// +using System; +using System.Collections.Generic; +using FluentCMS.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace FluentCMS.Infrastructure.Persistence.Migrations.PostgreSql +{ + [DbContext(typeof(PostgreSqlFluentCmsDbContext))] + [Migration("20261017230527_AuditEvents")] + partial class AuditEvents + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.13") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("FluentCMS.Infrastructure.Audit.AuditEventRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AgentCredentialId") + .HasColumnType("uuid"); + + b.Property("ChangeSetId") + .HasColumnType("uuid"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("InitiatorKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("InitiatorName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Model") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Operation") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("PreviousVersionId") + .HasColumnType("uuid"); + + b.Property("PromptHash") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.PrimitiveCollection>("Sources") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("SubjectId") + .HasColumnType("uuid"); + + b.Property("SubjectType") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("AgentCredentialId"); + + b.HasIndex("ChangeSetId"); + + b.HasIndex("Timestamp"); + + b.HasIndex("SubjectType", "SubjectId"); + + b.ToTable("AuditEvents", null, t => + { + t.HasCheckConstraint("CK_AuditEvents_AgentCredentialId", "(\"InitiatorKind\" = 'agent') = (\"AgentCredentialId\" IS NOT NULL)"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Persistence.FluentCmsRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("Roles", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Persistence.FluentCmsUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("Users", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("RoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("UserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("UserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("UserTokens", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261017230527_AuditEvents.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261017230527_AuditEvents.cs new file mode 100644 index 000000000..a312992ff --- /dev/null +++ b/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261017230527_AuditEvents.cs @@ -0,0 +1,72 @@ +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FluentCMS.Infrastructure.Persistence.Migrations.PostgreSql +{ + /// + public partial class AuditEvents : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "AuditEvents", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Timestamp = table.Column(type: "timestamp with time zone", nullable: false), + InitiatorKind = table.Column(type: "character varying(16)", maxLength: 16, nullable: false), + InitiatorId = table.Column(type: "uuid", nullable: false), + InitiatorName = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), + AgentCredentialId = table.Column(type: "uuid", nullable: true), + Operation = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + SubjectType = table.Column(type: "character varying(64)", maxLength: 64, nullable: true), + SubjectId = table.Column(type: "uuid", nullable: true), + Model = table.Column(type: "character varying(128)", maxLength: 128, nullable: true), + PromptHash = table.Column(type: "character varying(128)", maxLength: 128, nullable: true), + Sources = table.Column>(type: "text[]", nullable: false), + ChangeSetId = table.Column(type: "uuid", nullable: true), + PreviousVersionId = table.Column(type: "uuid", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AuditEvents", x => x.Id); + table.CheckConstraint("CK_AuditEvents_AgentCredentialId", "(\"InitiatorKind\" = 'agent') = (\"AgentCredentialId\" IS NOT NULL)"); + }); + + migrationBuilder.CreateIndex( + name: "IX_AuditEvents_AgentCredentialId", + table: "AuditEvents", + column: "AgentCredentialId"); + + migrationBuilder.CreateIndex( + name: "IX_AuditEvents_ChangeSetId", + table: "AuditEvents", + column: "ChangeSetId"); + + migrationBuilder.CreateIndex( + name: "IX_AuditEvents_SubjectType_SubjectId", + table: "AuditEvents", + columns: new[] { "SubjectType", "SubjectId" }); + + migrationBuilder.CreateIndex( + name: "IX_AuditEvents_Timestamp", + table: "AuditEvents", + column: "Timestamp"); + + // Append-only (AUD-N2, ADR-0005). The role applying this migration owns the table, so the revoke binds it too; + // a superuser bypasses privilege checks, so the deploying role must not be one for this to hold. + migrationBuilder.Sql("REVOKE UPDATE, DELETE, TRUNCATE ON \"AuditEvents\" FROM PUBLIC, CURRENT_USER;"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "AuditEvents"); + } + } +} diff --git a/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/PostgreSqlFluentCmsDbContextModelSnapshot.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/PostgreSqlFluentCmsDbContextModelSnapshot.cs index 285b5ddbc..cbc3fb059 100644 --- a/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/PostgreSqlFluentCmsDbContextModelSnapshot.cs +++ b/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/PostgreSqlFluentCmsDbContextModelSnapshot.cs @@ -1,5 +1,6 @@ // using System; +using System.Collections.Generic; using FluentCMS.Infrastructure.Persistence; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; @@ -22,6 +23,76 @@ protected override void BuildModel(ModelBuilder modelBuilder) NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + modelBuilder.Entity("FluentCMS.Infrastructure.Audit.AuditEventRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AgentCredentialId") + .HasColumnType("uuid"); + + b.Property("ChangeSetId") + .HasColumnType("uuid"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("InitiatorKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("InitiatorName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Model") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Operation") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("PreviousVersionId") + .HasColumnType("uuid"); + + b.Property("PromptHash") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.PrimitiveCollection>("Sources") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("SubjectId") + .HasColumnType("uuid"); + + b.Property("SubjectType") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("AgentCredentialId"); + + b.HasIndex("ChangeSetId"); + + b.HasIndex("Timestamp"); + + b.HasIndex("SubjectType", "SubjectId"); + + b.ToTable("AuditEvents", null, t => + { + t.HasCheckConstraint("CK_AuditEvents_AgentCredentialId", "(\"InitiatorKind\" = 'agent') = (\"AgentCredentialId\" IS NOT NULL)"); + }); + }); + modelBuilder.Entity("FluentCMS.Infrastructure.Persistence.FluentCmsRole", b => { b.Property("Id") diff --git a/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261017230521_AuditEvents.Designer.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261017230521_AuditEvents.Designer.cs new file mode 100644 index 000000000..5df336530 --- /dev/null +++ b/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261017230521_AuditEvents.Designer.cs @@ -0,0 +1,345 @@ +// +using System; +using FluentCMS.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace FluentCMS.Infrastructure.Persistence.Migrations.Sqlite +{ + [DbContext(typeof(SqliteFluentCmsDbContext))] + [Migration("20261017230521_AuditEvents")] + partial class AuditEvents + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.13"); + + modelBuilder.Entity("FluentCMS.Infrastructure.Audit.AuditEventRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AgentCredentialId") + .HasColumnType("TEXT"); + + b.Property("ChangeSetId") + .HasColumnType("TEXT"); + + b.Property("InitiatorId") + .HasColumnType("TEXT"); + + b.Property("InitiatorKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("InitiatorName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Model") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Operation") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PreviousVersionId") + .HasColumnType("TEXT"); + + b.Property("PromptHash") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.PrimitiveCollection("Sources") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SubjectId") + .HasColumnType("TEXT"); + + b.Property("SubjectType") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AgentCredentialId"); + + b.HasIndex("ChangeSetId"); + + b.HasIndex("Timestamp"); + + b.HasIndex("SubjectType", "SubjectId"); + + b.ToTable("AuditEvents", null, t => + { + t.HasCheckConstraint("CK_AuditEvents_AgentCredentialId", "(\"InitiatorKind\" = 'agent') = (\"AgentCredentialId\" IS NOT NULL)"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Persistence.FluentCmsRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("Roles", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Persistence.FluentCmsUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("Users", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("RoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("UserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("UserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("UserTokens", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261017230521_AuditEvents.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261017230521_AuditEvents.cs new file mode 100644 index 000000000..4d2b5cad8 --- /dev/null +++ b/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261017230521_AuditEvents.cs @@ -0,0 +1,67 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FluentCMS.Infrastructure.Persistence.Migrations.Sqlite +{ + /// + public partial class AuditEvents : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "AuditEvents", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + Timestamp = table.Column(type: "TEXT", nullable: false), + InitiatorKind = table.Column(type: "TEXT", maxLength: 16, nullable: false), + InitiatorId = table.Column(type: "TEXT", nullable: false), + InitiatorName = table.Column(type: "TEXT", maxLength: 256, nullable: false), + AgentCredentialId = table.Column(type: "TEXT", nullable: true), + Operation = table.Column(type: "TEXT", maxLength: 128, nullable: false), + SubjectType = table.Column(type: "TEXT", maxLength: 64, nullable: true), + SubjectId = table.Column(type: "TEXT", nullable: true), + Model = table.Column(type: "TEXT", maxLength: 128, nullable: true), + PromptHash = table.Column(type: "TEXT", maxLength: 128, nullable: true), + Sources = table.Column(type: "TEXT", nullable: false), + ChangeSetId = table.Column(type: "TEXT", nullable: true), + PreviousVersionId = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AuditEvents", x => x.Id); + table.CheckConstraint("CK_AuditEvents_AgentCredentialId", "(\"InitiatorKind\" = 'agent') = (\"AgentCredentialId\" IS NOT NULL)"); + }); + + migrationBuilder.CreateIndex( + name: "IX_AuditEvents_AgentCredentialId", + table: "AuditEvents", + column: "AgentCredentialId"); + + migrationBuilder.CreateIndex( + name: "IX_AuditEvents_ChangeSetId", + table: "AuditEvents", + column: "ChangeSetId"); + + migrationBuilder.CreateIndex( + name: "IX_AuditEvents_SubjectType_SubjectId", + table: "AuditEvents", + columns: new[] { "SubjectType", "SubjectId" }); + + migrationBuilder.CreateIndex( + name: "IX_AuditEvents_Timestamp", + table: "AuditEvents", + column: "Timestamp"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "AuditEvents"); + } + } +} diff --git a/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/SqliteFluentCmsDbContextModelSnapshot.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/SqliteFluentCmsDbContextModelSnapshot.cs index 167b4abab..23f5f0107 100644 --- a/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/SqliteFluentCmsDbContextModelSnapshot.cs +++ b/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/SqliteFluentCmsDbContextModelSnapshot.cs @@ -17,6 +17,76 @@ protected override void BuildModel(ModelBuilder modelBuilder) #pragma warning disable 612, 618 modelBuilder.HasAnnotation("ProductVersion", "10.0.13"); + modelBuilder.Entity("FluentCMS.Infrastructure.Audit.AuditEventRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AgentCredentialId") + .HasColumnType("TEXT"); + + b.Property("ChangeSetId") + .HasColumnType("TEXT"); + + b.Property("InitiatorId") + .HasColumnType("TEXT"); + + b.Property("InitiatorKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("InitiatorName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Model") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Operation") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PreviousVersionId") + .HasColumnType("TEXT"); + + b.Property("PromptHash") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.PrimitiveCollection("Sources") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SubjectId") + .HasColumnType("TEXT"); + + b.Property("SubjectType") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AgentCredentialId"); + + b.HasIndex("ChangeSetId"); + + b.HasIndex("Timestamp"); + + b.HasIndex("SubjectType", "SubjectId"); + + b.ToTable("AuditEvents", null, t => + { + t.HasCheckConstraint("CK_AuditEvents_AgentCredentialId", "(\"InitiatorKind\" = 'agent') = (\"AgentCredentialId\" IS NOT NULL)"); + }); + }); + modelBuilder.Entity("FluentCMS.Infrastructure.Persistence.FluentCmsRole", b => { b.Property("Id") diff --git a/src/FluentCMS.Infrastructure/Persistence/UnitOfWork.cs b/src/FluentCMS.Infrastructure/Persistence/UnitOfWork.cs new file mode 100644 index 000000000..15528872f --- /dev/null +++ b/src/FluentCMS.Infrastructure/Persistence/UnitOfWork.cs @@ -0,0 +1,87 @@ +using FluentCMS.Core.Persistence; +using Microsoft.EntityFrameworkCore.Storage; + +namespace FluentCMS.Infrastructure.Persistence; + +// One database transaction per outermost scope; inner scopes flush into it and cannot commit it on their own. +internal sealed class UnitOfWork(FluentCmsDbContext context) : IUnitOfWork +{ + private IDbContextTransaction? _transaction; + private int _depth; + private bool _abandoned; + + public async Task BeginAsync(CancellationToken cancellationToken = default) + { + if (_depth == 0) + { + _transaction = await context.Database.BeginTransactionAsync(cancellationToken); + _abandoned = false; + } + + _depth++; + return new Scope(this); + } + + private async Task CommitAsync(CancellationToken cancellationToken) + { + if (_abandoned) + { + throw new InvalidOperationException("An inner unit of work was disposed without committing, so the outer one cannot commit."); + } + + await context.SaveChangesAsync(cancellationToken); + + if (_depth == 1) + { + await _transaction!.CommitAsync(cancellationToken); + } + } + + private async ValueTask EndAsync(bool committed) + { + _depth--; + _abandoned |= !committed; + + if (_depth > 0) + { + return; + } + + // Disposing an uncommitted transaction rolls it back; the tracker is cleared so nothing from it leaks into the next scope. + await _transaction!.DisposeAsync(); + _transaction = null; + + if (_abandoned) + { + context.ChangeTracker.Clear(); + } + } + + private sealed class Scope(UnitOfWork owner) : IUnitOfWorkScope + { + private bool _committed; + private bool _ended; + + public async Task CommitAsync(CancellationToken cancellationToken = default) + { + if (_committed || _ended) + { + throw new InvalidOperationException("This unit of work scope has already been committed or disposed."); + } + + await owner.CommitAsync(cancellationToken); + _committed = true; + } + + public async ValueTask DisposeAsync() + { + if (_ended) + { + return; + } + + _ended = true; + await owner.EndAsync(_committed); + } + } +} diff --git a/tests/FluentCMS.IntegrationTests/Audit/AuditEventsPostgresTests.cs b/tests/FluentCMS.IntegrationTests/Audit/AuditEventsPostgresTests.cs new file mode 100644 index 000000000..ab4dfd7f2 --- /dev/null +++ b/tests/FluentCMS.IntegrationTests/Audit/AuditEventsPostgresTests.cs @@ -0,0 +1,82 @@ +using FluentCMS.Core.Audit; +using FluentCMS.Core.Identity; +using FluentCMS.Core.Persistence; +using FluentCMS.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Npgsql; + +namespace FluentCMS.IntegrationTests.Audit; + +public sealed class AuditEventsPostgresTests +{ + // The CI role is a superuser, which no revoke binds, so the migration is applied by a throwaway ordinary role that owns + // its own database: the shape of a deployment whose host migrates at startup. Needs a connection that may create roles + // and databases, which the documented local setup and CI both have. + [PostgresFact] + public async Task A_non_superuser_deploying_role_can_append_but_not_update_delete_or_truncate_audit_events() + { + var admin = new NpgsqlConnectionStringBuilder(Environment.GetEnvironmentVariable(PostgresFactAttribute.VariableName)!); + var suffix = Guid.NewGuid().ToString("N")[..8]; + var role = $"fluentcms_deployer_{suffix}"; + var databaseName = $"fluentcms_audit_{suffix}"; + var password = Guid.NewGuid().ToString("N"); + + await ExecuteAsAdminAsync(admin, $"CREATE ROLE \"{role}\" LOGIN PASSWORD '{password}'", $"CREATE DATABASE \"{databaseName}\" OWNER \"{role}\""); + try + { + var deployer = new NpgsqlConnectionStringBuilder(admin.ConnectionString) + { + Database = databaseName, + Username = role, + Password = password, + }; + + await using var services = TestServices.Build( + new Dictionary { ["Database:ConnectionString"] = deployer.ConnectionString }, + collection => collection.AddScoped(_ => new TestInitiator(new UserInitiator(Guid.NewGuid(), "Deployer")))); + await TestServices.MigrateAsync(services); + + await using var scope = services.CreateAsyncScope(); + var context = scope.ServiceProvider.GetRequiredService(); + + await using (var work = await scope.ServiceProvider.GetRequiredService().BeginAsync()) + { + await scope.ServiceProvider.GetRequiredService().AppendAsync(new NewAuditEvent("test.appended")); + await work.CommitAsync(); + } + + Assert.Equal(1, await context.AuditEvents.CountAsync()); + + await AssertInsufficientPrivilegeAsync(context, "UPDATE \"AuditEvents\" SET \"Operation\" = 'tampered'"); + await AssertInsufficientPrivilegeAsync(context, "DELETE FROM \"AuditEvents\""); + await AssertInsufficientPrivilegeAsync(context, "TRUNCATE \"AuditEvents\""); + + Assert.Equal("test.appended", (await context.AuditEvents.SingleAsync()).Operation); + } + finally + { + NpgsqlConnection.ClearAllPools(); + await ExecuteAsAdminAsync(admin, $"DROP DATABASE IF EXISTS \"{databaseName}\" WITH (FORCE)", $"DROP ROLE IF EXISTS \"{role}\""); + } + } + + private static async Task AssertInsufficientPrivilegeAsync(FluentCmsDbContext context, string statement) + { + var exception = await Assert.ThrowsAsync(() => context.Database.ExecuteSqlRawAsync(statement)); + + Assert.Equal(PostgresErrorCodes.InsufficientPrivilege, exception.SqlState); + } + + private static async Task ExecuteAsAdminAsync(NpgsqlConnectionStringBuilder admin, params string[] statements) + { + await using var connection = new NpgsqlConnection(admin.ConnectionString); + await connection.OpenAsync(); + + foreach (var statement in statements) + { + await using var command = new NpgsqlCommand(statement, connection); + await command.ExecuteNonQueryAsync(); + } + } +} diff --git a/tests/FluentCMS.IntegrationTests/Audit/AuditLogTests.cs b/tests/FluentCMS.IntegrationTests/Audit/AuditLogTests.cs new file mode 100644 index 000000000..fe34b5f5c --- /dev/null +++ b/tests/FluentCMS.IntegrationTests/Audit/AuditLogTests.cs @@ -0,0 +1,220 @@ +using FluentCMS.Core.Audit; +using FluentCMS.Core.Identity; +using FluentCMS.Core.Persistence; +using FluentCMS.Infrastructure.Audit; +using FluentCMS.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace FluentCMS.IntegrationTests.Audit; + +public sealed class AuditLogTests +{ + private static readonly DateTimeOffset Now = new(2026, 10, 17, 12, 0, 0, TimeSpan.Zero); + private static readonly UserInitiator Amir = new(Guid.NewGuid(), "Amir"); + private static readonly AgentInitiator CatalogSync = new(Guid.NewGuid(), "catalog-sync"); + + [Fact] + public async Task An_event_appended_inside_a_unit_of_work_is_persisted_with_the_current_user_and_every_field() + { + using var database = new TemporarySqliteDatabase(); + await using var services = await BuildAsync(database, Amir); + var subjectId = Guid.NewGuid(); + var changeSetId = Guid.NewGuid(); + var previousVersionId = Guid.NewGuid(); + AuditEvent appended; + + await using (var scope = services.CreateAsyncScope()) + { + await using var work = await scope.ServiceProvider.GetRequiredService().BeginAsync(); + appended = await scope.ServiceProvider.GetRequiredService().AppendAsync(new NewAuditEvent("entry.updated") + { + SubjectType = "entry", + SubjectId = subjectId, + Model = "claude-sonnet-4-5", + PromptHash = "sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", + Sources = ["https://example.com/prices", "Dana, fact owner"], + ChangeSetId = changeSetId, + PreviousVersionId = previousVersionId, + }); + await work.CommitAsync(); + } + + Assert.Same(Amir, appended.Initiator); + Assert.Equal(Now, appended.Timestamp); + + var row = Assert.Single(await RowsAsync(services)); + Assert.Equal(appended.Id, row.Id); + Assert.Equal(Now, row.Timestamp); + Assert.Equal(InitiatorKinds.User, row.InitiatorKind); + Assert.Equal(Amir.UserId, row.InitiatorId); + Assert.Equal("Amir", row.InitiatorName); + Assert.Null(row.AgentCredentialId); + Assert.Equal("entry.updated", row.Operation); + Assert.Equal("entry", row.SubjectType); + Assert.Equal(subjectId, row.SubjectId); + Assert.Equal("claude-sonnet-4-5", row.Model); + Assert.StartsWith("sha256:", row.PromptHash); + Assert.Equal(["https://example.com/prices", "Dana, fact owner"], row.Sources); + Assert.Equal(changeSetId, row.ChangeSetId); + Assert.Equal(previousVersionId, row.PreviousVersionId); + } + + [Fact] + public async Task An_agent_event_records_the_credential_it_acted_under() + { + using var database = new TemporarySqliteDatabase(); + await using var services = await BuildAsync(database, CatalogSync); + + await using (var scope = services.CreateAsyncScope()) + { + await using var work = await scope.ServiceProvider.GetRequiredService().BeginAsync(); + await scope.ServiceProvider.GetRequiredService().AppendAsync(new NewAuditEvent("entry.created")); + await work.CommitAsync(); + } + + var row = Assert.Single(await RowsAsync(services)); + Assert.Equal(InitiatorKinds.Agent, row.InitiatorKind); + Assert.Equal(CatalogSync.CredentialId, row.InitiatorId); + Assert.Equal(CatalogSync.CredentialId, row.AgentCredentialId); + Assert.Equal("catalog-sync", row.InitiatorName); + Assert.Empty(row.Sources); + } + + [Fact] + public async Task A_unit_of_work_disposed_without_commit_leaves_no_event_and_no_change_behind() + { + using var database = new TemporarySqliteDatabase(); + await using var services = await BuildAsync(database, Amir); + await using var scope = services.CreateAsyncScope(); + var directory = scope.ServiceProvider.GetRequiredService(); + + // Identity saves the user on its own inside the scope; the rollback still takes it away together with the event (AUD-N1). + await using (await scope.ServiceProvider.GetRequiredService().BeginAsync()) + { + var created = await directory.CreateAsync(new NewUser("owner@example.com", "Owner", "Str0ng!Passw0rd")); + Assert.True(created.IsSuccess, string.Join("; ", created.Errors.Select(error => error.Message))); + + await scope.ServiceProvider.GetRequiredService().AppendAsync( + new NewAuditEvent("user.created") { SubjectType = "user", SubjectId = created.Value }); + } + + Assert.Empty(await RowsAsync(services)); + Assert.Empty(await directory.ListAsync()); + } + + [Fact] + public async Task Appending_outside_a_unit_of_work_is_rejected() + { + using var database = new TemporarySqliteDatabase(); + await using var services = await BuildAsync(database, Amir); + await using var scope = services.CreateAsyncScope(); + var log = scope.ServiceProvider.GetRequiredService(); + + var exception = await Assert.ThrowsAsync(() => log.AppendAsync(new NewAuditEvent("entry.created"))); + + Assert.Contains("unit of work", exception.Message); + Assert.Empty(await RowsAsync(services)); + } + + [Fact] + public async Task Appending_with_no_known_initiator_is_rejected() + { + using var database = new TemporarySqliteDatabase(); + await using var services = await BuildAsync(database, initiator: null); + await using var scope = services.CreateAsyncScope(); + var log = scope.ServiceProvider.GetRequiredService(); + + await using (var work = await scope.ServiceProvider.GetRequiredService().BeginAsync()) + { + var exception = await Assert.ThrowsAsync(() => log.AppendAsync(new NewAuditEvent("job.ran"))); + Assert.Contains("initiator", exception.Message); + await work.CommitAsync(); + } + + Assert.Empty(await RowsAsync(services)); + } + + [Fact] + public async Task Inner_scopes_flush_into_the_outer_transaction_and_only_the_outer_commit_makes_them_visible() + { + using var database = new TemporarySqliteDatabase(); + await using var services = await BuildAsync(database, Amir); + await using var scope = services.CreateAsyncScope(); + var unitOfWork = scope.ServiceProvider.GetRequiredService(); + var log = scope.ServiceProvider.GetRequiredService(); + + await using (var outer = await unitOfWork.BeginAsync()) + { + await log.AppendAsync(new NewAuditEvent("outer.started")); + await using (var inner = await unitOfWork.BeginAsync()) + { + await log.AppendAsync(new NewAuditEvent("inner.done")); + await inner.CommitAsync(); + } + + await outer.CommitAsync(); + } + + Assert.Equal(["inner.done", "outer.started"], (await RowsAsync(services)).Select(row => row.Operation).Order()); + + await using (await unitOfWork.BeginAsync()) + { + await using var inner = await unitOfWork.BeginAsync(); + await log.AppendAsync(new NewAuditEvent("inner.committed_but_outer_abandoned")); + await inner.CommitAsync(); + } + + Assert.Equal(2, (await RowsAsync(services)).Count); + } + + [Fact] + public async Task An_inner_scope_disposed_without_commit_fails_the_outer_commit() + { + using var database = new TemporarySqliteDatabase(); + await using var services = await BuildAsync(database, Amir); + await using var scope = services.CreateAsyncScope(); + var unitOfWork = scope.ServiceProvider.GetRequiredService(); + var log = scope.ServiceProvider.GetRequiredService(); + + await using (var outer = await unitOfWork.BeginAsync()) + { + await log.AppendAsync(new NewAuditEvent("outer.started")); + await using (await unitOfWork.BeginAsync()) + { + await log.AppendAsync(new NewAuditEvent("inner.abandoned")); + } + + await Assert.ThrowsAsync(() => outer.CommitAsync()); + } + + Assert.Empty(await RowsAsync(services)); + + // The next unit of work on the same context starts clean. + await using (var work = await unitOfWork.BeginAsync()) + { + await log.AppendAsync(new NewAuditEvent("next.done")); + await work.CommitAsync(); + } + + Assert.Equal("next.done", Assert.Single(await RowsAsync(services)).Operation); + } + + private static async Task BuildAsync(TemporarySqliteDatabase database, Initiator? initiator) + { + var services = TestServices.Build(database.Configuration, collection => + { + collection.AddSingleton(new FixedClock(Now)); + collection.AddScoped(_ => new TestInitiator(initiator)); + }); + + await TestServices.MigrateAsync(services); + return services; + } + + private static async Task> RowsAsync(ServiceProvider services) + { + await using var scope = services.CreateAsyncScope(); + return await scope.ServiceProvider.GetRequiredService().AuditEvents.ToListAsync(); + } +} diff --git a/tests/FluentCMS.IntegrationTests/DatabaseTests.cs b/tests/FluentCMS.IntegrationTests/DatabaseTests.cs index 9e60925e9..4484c50c9 100644 --- a/tests/FluentCMS.IntegrationTests/DatabaseTests.cs +++ b/tests/FluentCMS.IntegrationTests/DatabaseTests.cs @@ -21,6 +21,31 @@ public async Task Migrations_apply_on_sqlite_by_default() Assert.NotEmpty(await context.Database.GetAppliedMigrationsAsync()); Assert.Empty(await context.Database.GetPendingMigrationsAsync()); Assert.False(await context.Users.AnyAsync()); + Assert.False(await context.AuditEvents.AnyAsync()); + } + + // Upgrades must be reversible one version back (OPS-04, ADR-0005), so the newest migration's Down is exercised here. + [Fact] + public async Task The_newest_migration_reverts_and_reapplies_on_sqlite() + { + using var database = new TemporarySqliteDatabase(); + await using var services = TestServices.Build(database.Configuration); + await TestServices.MigrateAsync(services); + + await using var scope = services.CreateAsyncScope(); + var context = scope.ServiceProvider.GetRequiredService(); + var migrations = context.Database.GetMigrations().ToList(); + Assert.True(migrations.Count >= 2); + + await context.Database.MigrateAsync(migrations[^2]); + + Assert.Equal(migrations[..^1], await context.Database.GetAppliedMigrationsAsync()); + Assert.Equal([migrations[^1]], await context.Database.GetPendingMigrationsAsync()); + + await context.Database.MigrateAsync(); + + Assert.Empty(await context.Database.GetPendingMigrationsAsync()); + Assert.False(await context.AuditEvents.AnyAsync()); } [PostgresFact] @@ -41,6 +66,7 @@ public async Task Migrations_apply_on_postgresql_when_the_connection_string_says Assert.NotEmpty(await context.Database.GetAppliedMigrationsAsync()); Assert.Empty(await context.Database.GetPendingMigrationsAsync()); Assert.False(await context.Users.AnyAsync()); + Assert.False(await context.AuditEvents.AnyAsync()); } [Fact] diff --git a/tests/FluentCMS.IntegrationTests/TestDoubles.cs b/tests/FluentCMS.IntegrationTests/TestDoubles.cs new file mode 100644 index 000000000..fee800969 --- /dev/null +++ b/tests/FluentCMS.IntegrationTests/TestDoubles.cs @@ -0,0 +1,17 @@ +using FluentCMS.Core.Identity; + +namespace FluentCMS.IntegrationTests; + +// Stands in for the host's ICurrentInitiator, which Infrastructure expects the host to register. +internal sealed class TestInitiator(Initiator? current) : ICurrentInitiator +{ + public Initiator? Current { get; } = current; +} + +internal sealed class FixedClock(DateTimeOffset now) : TimeProvider +{ + public override DateTimeOffset GetUtcNow() + { + return now; + } +} diff --git a/tests/FluentCMS.IntegrationTests/TestServices.cs b/tests/FluentCMS.IntegrationTests/TestServices.cs index 470c60ac3..8463f5285 100644 --- a/tests/FluentCMS.IntegrationTests/TestServices.cs +++ b/tests/FluentCMS.IntegrationTests/TestServices.cs @@ -8,12 +8,14 @@ namespace FluentCMS.IntegrationTests; internal static class TestServices { - public static ServiceProvider Build(IEnumerable> settings) + public static ServiceProvider Build(IEnumerable> settings, Action? configure = null) { var configuration = new ConfigurationBuilder().AddInMemoryCollection(settings).Build(); var services = new ServiceCollection(); services.AddLogging(); + // Test doubles go in first so a test clock wins over the TimeProvider Infrastructure adds with TryAdd. + configure?.Invoke(services); services.AddFluentCmsInfrastructure(configuration); return services.BuildServiceProvider(); diff --git a/tests/FluentCMS.Tests/Audit/AuditEventTests.cs b/tests/FluentCMS.Tests/Audit/AuditEventTests.cs new file mode 100644 index 000000000..1e7785d36 --- /dev/null +++ b/tests/FluentCMS.Tests/Audit/AuditEventTests.cs @@ -0,0 +1,106 @@ +using FluentCMS.Core.Audit; +using FluentCMS.Core.Identity; + +namespace FluentCMS.Tests.Audit; + +public sealed class AuditEventTests +{ + private static readonly UserInitiator Amir = new(Guid.NewGuid(), "Amir"); + private static readonly AgentInitiator CatalogSync = new(Guid.NewGuid(), "catalog-sync"); + + [Fact] + public void A_user_event_names_no_agent_credential_and_leaves_unfilled_fields_empty() + { + var auditEvent = Event(Amir); + + Assert.Same(Amir, auditEvent.Initiator); + Assert.Null(auditEvent.AgentCredentialId); + Assert.Empty(auditEvent.Sources); + Assert.Null(auditEvent.SubjectType); + Assert.Null(auditEvent.SubjectId); + Assert.Null(auditEvent.Model); + Assert.Null(auditEvent.PromptHash); + Assert.Null(auditEvent.ChangeSetId); + Assert.Null(auditEvent.PreviousVersionId); + } + + [Fact] + public void An_agent_event_exposes_the_credential_it_acted_under() + { + var auditEvent = Event(CatalogSync); + + Assert.Equal(CatalogSync.CredentialId, auditEvent.AgentCredentialId); + } + + [Fact] + public void Every_AUD_01_field_is_kept() + { + var subjectId = Guid.NewGuid(); + var changeSetId = Guid.NewGuid(); + var previousVersionId = Guid.NewGuid(); + + var auditEvent = Event(CatalogSync) with + { + SubjectType = "entry", + SubjectId = subjectId, + Model = "claude-sonnet-4-5", + PromptHash = "sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", + Sources = ["https://example.com/prices", "Dana, fact owner"], + ChangeSetId = changeSetId, + PreviousVersionId = previousVersionId, + }; + + Assert.Equal("entry", auditEvent.SubjectType); + Assert.Equal(subjectId, auditEvent.SubjectId); + Assert.Equal("claude-sonnet-4-5", auditEvent.Model); + Assert.StartsWith("sha256:", auditEvent.PromptHash); + Assert.Equal(["https://example.com/prices", "Dana, fact owner"], auditEvent.Sources); + Assert.Equal(changeSetId, auditEvent.ChangeSetId); + Assert.Equal(previousVersionId, auditEvent.PreviousVersionId); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void A_blank_operation_is_rejected(string operation) + { + var exception = Assert.Throws(() => Event(Amir) with { Operation = operation }); + + Assert.Equal("Operation", exception.ParamName); + } + + [Fact] + public void A_missing_operation_or_initiator_is_rejected() + { + Assert.Throws(() => Event(Amir) with { Operation = null! }); + Assert.Throws(() => Event(Amir) with { Initiator = null! }); + } + + [Fact] + public void An_empty_id_is_rejected() + { + var exception = Assert.Throws(() => Event(Amir) with { Id = Guid.Empty }); + + Assert.Equal("Id", exception.ParamName); + } + + [Fact] + public void A_blank_or_missing_source_list_entry_is_rejected() + { + var exception = Assert.Throws(() => Event(Amir) with { Sources = ["https://example.com", " "] }); + + Assert.Equal("Sources", exception.ParamName); + Assert.Throws(() => Event(Amir) with { Sources = null! }); + } + + private static AuditEvent Event(Initiator initiator) + { + return new AuditEvent + { + Id = Guid.NewGuid(), + Timestamp = new DateTimeOffset(2026, 10, 17, 12, 0, 0, TimeSpan.Zero), + Initiator = initiator, + Operation = "test.ran", + }; + } +}