Skip to content
Merged
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
6 changes: 6 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,12 @@ dotnet ef migrations add <Name> --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<TContract, TImplementation>()` 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<TContract>` 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.
Expand Down
63 changes: 63 additions & 0 deletions src/FluentCMS.Core/Audit/AuditEvent.cs
Original file line number Diff line number Diff line change
@@ -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<string> 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;
}
8 changes: 8 additions & 0 deletions src/FluentCMS.Core/Audit/IAuditLog.cs
Original file line number Diff line number Diff line change
@@ -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<AuditEvent> AppendAsync(NewAuditEvent entry, CancellationToken cancellationToken = default);
}
19 changes: 19 additions & 0 deletions src/FluentCMS.Core/Audit/NewAuditEvent.cs
Original file line number Diff line number Diff line change
@@ -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<string> Sources { get; init; } = [];

public Guid? ChangeSetId { get; init; }

public Guid? PreviousVersionId { get; init; }
}
14 changes: 14 additions & 0 deletions src/FluentCMS.Core/Persistence/IUnitOfWork.cs
Original file line number Diff line number Diff line change
@@ -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<IUnitOfWorkScope> 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);
}
29 changes: 29 additions & 0 deletions src/FluentCMS.Infrastructure/Audit/AuditEventConfiguration.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;

namespace FluentCMS.Infrastructure.Audit;

internal sealed class AuditEventConfiguration : IEntityTypeConfiguration<AuditEventRow>
{
public void Configure(EntityTypeBuilder<AuditEventRow> 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 });
}
}
70 changes: 70 additions & 0 deletions src/FluentCMS.Infrastructure/Audit/AuditEventRow.cs
Original file line number Diff line number Diff line change
@@ -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<string> 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";
}
43 changes: 43 additions & 0 deletions src/FluentCMS.Infrastructure/Audit/AuditLog.cs
Original file line number Diff line number Diff line change
@@ -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<AuditEvent> 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);
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -17,6 +20,7 @@ public static IServiceCollection AddFluentCmsInfrastructure(this IServiceCollect

AddDatabase(services, configuration.GetSection(DatabaseOptions.SectionName).Get<DatabaseOptions>() ?? new DatabaseOptions());
AddIdentity(services);
AddAudit(services);

services.AddHostedService<DatabaseMigrator>();

Expand All @@ -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<IUnitOfWork, UnitOfWork>();
services.AddScoped<IAuditLog, AuditLog>();
}

private static void AddIdentity(IServiceCollection services)
{
services.AddIdentityCore<FluentCmsUser>(options => options.User.RequireUniqueEmail = true)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using FluentCMS.Infrastructure.Audit;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
Expand All @@ -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<FluentCmsUser, FluentCmsRole, Guid>(options)
{
public DbSet<AuditEventRow> AuditEvents => Set<AuditEventRow>();

protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);

builder.ApplyConfiguration(new AuditEventConfiguration());

builder.Entity<FluentCmsUser>(user =>
{
user.ToTable("Users");
Expand Down
Loading
Loading