diff --git a/SW.Bitween.Api/Data/AuditPolicy.cs b/SW.Bitween.Api/Data/AuditPolicy.cs
new file mode 100644
index 00000000..27fe0a9e
--- /dev/null
+++ b/SW.Bitween.Api/Data/AuditPolicy.cs
@@ -0,0 +1,115 @@
+using System;
+using System.Collections.Generic;
+using Microsoft.EntityFrameworkCore.ChangeTracking;
+using Microsoft.EntityFrameworkCore.Metadata;
+using SW.Bitween.Domain;
+using SW.Bitween.Domain.Accounts;
+using SW.Bitween.Domain.Gateway;
+using SW.Bitween.Services;
+using SW.EfCoreExtensions;
+
+namespace SW.Bitween;
+
+///
+/// What the audit trail records, and what it must never record. Owned by C# in the same way as the
+/// permission catalog, so the rules sit in one readable list rather than being spread across the
+/// handlers that happen to write each entity.
+///
+///
+///
+/// Default deny. An entity absent from produces no audit rows at all.
+/// That is deliberate: everything in Bitween shares one , so an
+/// allow-everything policy would write several rows per message processed and bury the
+/// configuration changes the trail exists to show. It also means cannot
+/// audit itself.
+///
+///
+/// Credentials. names the properties that are never captured in any
+/// entity state. An audit table is read by more people than the screens that mask these values, so
+/// a property that is hidden in the UI and recorded here in full would be a leak wearing a
+/// different hat.
+///
+///
+public static class AuditPolicy
+{
+ ///
+ /// The configuration entities, their owned children, and the account/role records. Runtime
+ /// traffic — every Xchange, result, delivery, receive attempt, retry usage counter and
+ /// refresh token — is deliberately absent.
+ ///
+ private static readonly HashSet Audited =
+ [
+ typeof(Subscription),
+ typeof(Schedule), // owned by Subscription
+ typeof(SubscriptionCategory),
+ typeof(Partner),
+ typeof(ApiCredential), // owned by Partner
+ typeof(Document),
+ typeof(ApiGateway),
+ typeof(ApiGatewayPartner),
+ typeof(BusGateway),
+ typeof(BusGatewayRoute),
+ typeof(WorkGroup),
+ typeof(RetryPolicy),
+ typeof(RetryAlertOverride),
+ typeof(Notifier),
+ typeof(GlobalAdapterValuesSet),
+ typeof(Setting),
+ typeof(Account),
+ typeof(Role),
+ typeof(AccountRoleLink)
+ ];
+
+ ///
+ /// Properties whose values never enter the trail. The adapter property bags are here because
+ /// each is stored as a single JSON column, so the change tracker sees one property rather than
+ /// the individual keys — there is no way to keep Host and drop Password without
+ /// cracking the JSON open and asking the adapter which of its keys are [Secure], which
+ /// would mean starting a serverless adapter in the middle of a save. Recording that a
+ /// subscription was edited without saying which adapter key changed is the deliberate trade.
+ ///
+ private static readonly Dictionary> Redacted = new()
+ {
+ [typeof(Subscription)] =
+ [
+ nameof(Subscription.HandlerProperties),
+ nameof(Subscription.MapperProperties),
+ nameof(Subscription.ReceiverProperties),
+ nameof(Subscription.ValidatorProperties)
+ ],
+ [typeof(Partner)] = [nameof(Partner.AdapterProperties)],
+ [typeof(Notifier)] = [nameof(Notifier.HandlerProperties)],
+ [typeof(RetryPolicy)] = [nameof(RetryPolicy.AlertHandlerProperties)],
+ [typeof(RetryAlertOverride)] = [nameof(RetryAlertOverride.AlertHandlerProperties)],
+
+ // Free-form values an adapter reads by name. Nothing marks which of them is a credential,
+ // so there is no safe subset to keep.
+ [typeof(GlobalAdapterValuesSet)] = [nameof(GlobalAdapterValuesSet.Values)],
+
+ // The key itself is the credential. Name survives, so adding or revoking one is visible.
+ [typeof(ApiCredential)] = [nameof(ApiCredential.Key)],
+
+ [typeof(Account)] = [nameof(Account.Password)]
+ };
+
+ public static readonly AuditOptions Options = new()
+ {
+ ShouldAuditEntity = entry => Audited.Contains(entry.Metadata.ClrType),
+ ShouldAuditProperty = ShouldAuditProperty
+ };
+
+ private static bool ShouldAuditProperty(EntityEntry entry, IProperty property)
+ {
+ if (Redacted.TryGetValue(entry.Metadata.ClrType, out var redacted) &&
+ redacted.Contains(property.Name))
+ return false;
+
+ // A setting's value is only sometimes a secret, and unlike an adapter's properties the
+ // catalog says which — a static lookup, no adapter to start — so the theme colour stays
+ // readable in the trail while the licence key never enters it.
+ if (entry.Entity is Setting setting && property.Name == nameof(Setting.Value))
+ return SettingsCatalog.Find(setting.Id)?.Secret != true;
+
+ return true;
+ }
+}
diff --git a/SW.Bitween.Api/Data/BitweenDbContext.cs b/SW.Bitween.Api/Data/BitweenDbContext.cs
index a95975e2..982fb04f 100644
--- a/SW.Bitween.Api/Data/BitweenDbContext.cs
+++ b/SW.Bitween.Api/Data/BitweenDbContext.cs
@@ -59,21 +59,6 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
b.HasData(new Document(Document.AggregationDocumentId, "Aggregation Document"));
});
- modelBuilder.Entity(b =>
- {
- b.HasKey(i => i.Id);
- b.Property(p => p.Id).HasMaxLength(50);
- b.HasIndex(i => i.CreatedOn);
- b.HasOne(i => i.Document).WithMany().HasForeignKey(i => i.DocumentId);
- });
-
- modelBuilder.Entity(b =>
- {
- b.HasKey(i => i.Id);
- b.Property(p => p.Id).HasMaxLength(50);
- b.HasIndex(i => i.CreatedOn);
- b.HasOne(i => i.Subscription).WithMany().HasForeignKey(i => i.SubscriptionId);
- });
modelBuilder.Entity(cr =>
{
cr.HasNoKey().ToView(null);
@@ -450,6 +435,26 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
b.Property(p => p.Id).IsUnicode(false).HasMaxLength(200);
});
+ // ——— Audit trail ———
+ // A table that only ever grows and is only ever appended to. The indexes answer the two
+ // questions asked of it: the history of one row, and everything one save changed.
+ modelBuilder.Entity(b =>
+ {
+ b.ToTable("AuditEntries");
+ b.HasKey(p => p.Id);
+ b.Property(p => p.Id).IsUnicode(false).HasMaxLength(32);
+ // 36, not 32: the library builds these with Guid.ToString(), which keeps the hyphens.
+ b.Property(p => p.CorrelationId).IsUnicode(false).HasMaxLength(36).IsRequired();
+ b.Property(p => p.UserId).IsUnicode(false).HasMaxLength(50);
+ b.Property(p => p.EntityName).HasMaxLength(200).IsRequired();
+ b.Property(p => p.EntityKey).HasMaxLength(200);
+ b.Property(p => p.State).IsUnicode(false).HasMaxLength(10).IsRequired();
+
+ b.HasIndex(p => new { p.EntityName, p.EntityKey, p.OccurredOn });
+ b.HasIndex(p => p.CorrelationId);
+ b.HasIndex(p => p.OccurredOn);
+ });
+
}
///
@@ -469,11 +474,63 @@ protected Role[] SystemRoleSeed() =>
{ CreatedOn = defaultCreatedOn.ToUniversalTime() }
];
+ ///
+ /// Refused, because it would save without auditing. Only
+ /// captures the trail, and an audit table with a silent hole in it is worse than none —
+ /// nobody would know which changes it had missed. Nothing in Bitween calls this today;
+ /// this makes sure a future caller finds out immediately rather than quietly.
+ ///
+ public override int SaveChanges() => throw new NotSupportedException(
+ "Use SaveChangesAsync — the synchronous path would skip the audit trail.");
+
+ ///
+ /// Saves, and records what was saved. The audit rows are written inside the same transaction
+ /// as the change they describe, so the trail can never disagree with the data — a save that
+ /// rolls back takes its audit rows with it.
+ ///
+ ///
+ /// The transaction is opened only when there is something to audit. Runtime traffic — every
+ /// xchange, result and receive attempt — is excluded by and so
+ /// still saves in exactly one round trip, unchanged. When a caller has already opened a
+ /// transaction of its own, that one is used rather than nested.
+ ///
async public override Task SaveChangesAsync(CancellationToken cancellationToken = default)
{
- ChangeTracker.ApplyAuditValues(requestContext.GetNameIdentifier());
- //using var transaction = await Database.BeginTransactionAsync();
- var affectedRecords = await base.SaveChangesAsync(cancellationToken);
+ var userId = requestContext.GetNameIdentifier();
+ ChangeTracker.ApplyAuditValues(userId);
+
+ var pendingAudit = ChangeTracker.CapturePendingAuditDiffs(userId, AuditPolicy.Options);
+
+ var transaction = pendingAudit.Count > 0 && Database.CurrentTransaction is null
+ ? await Database.BeginTransactionAsync(cancellationToken)
+ : null;
+
+ int affectedRecords;
+ try
+ {
+ affectedRecords = await base.SaveChangesAsync(cancellationToken);
+
+ if (pendingAudit.Count > 0)
+ {
+ // Finalized after the save because that is when a generated primary key exists;
+ // an entry for a newly created row would otherwise record no key at all.
+ foreach (var diff in pendingAudit.FinalizeAuditDiffJson())
+ Add(new AuditEntry(diff));
+
+ // base, deliberately: re-entering this override would audit the audit rows and
+ // publish every domain event a second time.
+ await base.SaveChangesAsync(cancellationToken);
+ }
+
+ if (transaction is not null) await transaction.CommitAsync(cancellationToken);
+ }
+ finally
+ {
+ if (transaction is not null) await transaction.DisposeAsync();
+ }
+
+ // Published after the commit. An event announcing a change that then rolled back would
+ // send every consumer after a row that never existed.
//await ChangeTracker.PublishDomainEvents(publish);
var entitiesWithEvents = ChangeTracker.Entries()
.Select(e => e.Entity)
diff --git a/SW.Bitween.Api/Domain/Audit/AuditEntry.cs b/SW.Bitween.Api/Domain/Audit/AuditEntry.cs
new file mode 100644
index 00000000..05bb1c7f
--- /dev/null
+++ b/SW.Bitween.Api/Domain/Audit/AuditEntry.cs
@@ -0,0 +1,85 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Newtonsoft.Json;
+using SW.EfCoreExtensions;
+using SW.PrimitiveTypes;
+
+namespace SW.Bitween.Domain;
+
+///
+/// One configuration change, as the change tracker saw it: which entity, which properties, from what
+/// to what, by whom.
+///
+///
+///
+/// Rows are written by for the entities
+/// admits, in the same transaction as the change itself — so an entry
+/// exists for every audited change that committed, and for no change that didn't.
+///
+///
+/// Nothing edits or deletes an entry. That is the point of the table, and it is why the setters are
+/// private and there is no update path anywhere in the code.
+///
+///
+public class AuditEntry : BaseEntity
+{
+ private AuditEntry()
+ {
+ }
+
+ public AuditEntry(GenericAuditDiffJson diff)
+ {
+ Id = Guid.NewGuid().ToString("N");
+ CorrelationId = diff.CorrelationId;
+ Sequence = diff.Sequence;
+ // The library hands back a DateTimeOffset. Every other timestamp in Bitween is a UTC
+ // DateTime, and MySQL has no offset type to store one in, so it is flattened here rather
+ // than becoming the one column that behaves differently on one of the three providers.
+ OccurredOn = diff.Timestamp.UtcDateTime;
+ UserId = diff.UserId;
+ EntityName = diff.EntityName;
+ EntityKey = FlattenKey(diff.PrimaryKey);
+ State = diff.State;
+ Changes = JsonConvert.SerializeObject(diff.Changes);
+ }
+
+ /// Shared by every entry written by the same save, so one request reads as one change.
+ public string CorrelationId { get; private set; }
+
+ /// Position within that save, 1-based. Only meaningful alongside .
+ public int Sequence { get; private set; }
+
+ public DateTime OccurredOn { get; private set; }
+
+ ///
+ /// The account id behind the change, or null for a save with no signed-in user — a bus consumer
+ /// or a scheduled job. Kept as the id rather than a name so that renaming an account doesn't
+ /// rewrite history.
+ ///
+ public string UserId { get; private set; }
+
+ /// The entity's display name, e.g. Subscription.
+ public string EntityName { get; private set; }
+
+ ///
+ /// The primary key as a single value, so the history of one row is an indexed lookup rather than
+ /// a JSON scan. Composite keys are joined with | in property-name order.
+ ///
+ public string EntityKey { get; private set; }
+
+ /// Added, Modified or Deleted.
+ public string State { get; private set; }
+
+ ///
+ /// The per-property before and after values, as a JSON object of
+ /// { "PropertyName": { "Old": …, "New": … } }. Properties excluded by
+ /// never reach it.
+ ///
+ public string Changes { get; private set; }
+
+ private static string FlattenKey(object primaryKey) =>
+ primaryKey is IDictionary values
+ ? string.Join("|", values.OrderBy(kv => kv.Key).Select(kv => kv.Value?.ToString()))
+ : primaryKey?.ToString();
+}
diff --git a/SW.Bitween.Api/Domain/Document/DocumentTrail.cs b/SW.Bitween.Api/Domain/Document/DocumentTrail.cs
deleted file mode 100644
index 5971f212..00000000
--- a/SW.Bitween.Api/Domain/Document/DocumentTrail.cs
+++ /dev/null
@@ -1,48 +0,0 @@
-using System;
-using Newtonsoft.Json;
-using SW.PrimitiveTypes;
-
-namespace SW.Bitween.Domain;
-
-public class DocumentTrail : BaseEntity, ICreationAudited
-{
- private DocumentTrail()
- {
- }
-
-
- public DocumentTrail(DocumentTrailCode code, Document document,bool isNew = false)
- {
- Id = Guid.NewGuid().ToString("N");
-
- Code = code;
- if (isNew)
- {
- StateBefore = "{}";
- StateAfter = JsonConvert.SerializeObject(document);
- }
- else
- {
- StateBefore = JsonConvert.SerializeObject(document);
- StateAfter = "{}";
- }
-
- Document = document;
- }
- public void SetAfter(Document stateAfter)
- {
- StateAfter = JsonConvert.SerializeObject(stateAfter);
- }
-
- public int DocumentId { get; private set; }
- public Document Document { get; private set; }
-
- public DocumentTrailCode Code { get; private set; }
-
- public string StateBefore { get; private set; }
-
- public string StateAfter { get; private set; }
-
- public DateTime CreatedOn { get; set; }
- public string CreatedBy { get; set; }
-}
\ No newline at end of file
diff --git a/SW.Bitween.Api/Domain/Document/DocumentTrialCode.cs b/SW.Bitween.Api/Domain/Document/DocumentTrialCode.cs
deleted file mode 100644
index ebb74986..00000000
--- a/SW.Bitween.Api/Domain/Document/DocumentTrialCode.cs
+++ /dev/null
@@ -1,7 +0,0 @@
-namespace SW.Bitween.Domain;
-
-public enum DocumentTrailCode
-{
- Created = 11,
- Updated = 21
-}
\ No newline at end of file
diff --git a/SW.Bitween.Api/Domain/Subscription/SubscriptionTrail.cs b/SW.Bitween.Api/Domain/Subscription/SubscriptionTrail.cs
deleted file mode 100644
index 3c92859f..00000000
--- a/SW.Bitween.Api/Domain/Subscription/SubscriptionTrail.cs
+++ /dev/null
@@ -1,47 +0,0 @@
-using System;
-using Newtonsoft.Json;
-using SW.PrimitiveTypes;
-
-namespace SW.Bitween.Domain;
-
-public class SubscriptionTrail : BaseEntity, ICreationAudited
-{
- private SubscriptionTrail()
- {
- }
-
-
- public SubscriptionTrail(SubscriptionTrialCode code, Subscription subscription, bool isNew = false)
- {
- Id = Guid.NewGuid().ToString("N");
- Code = code;
- if (isNew)
- {
- StateBefore = "{}";
- StateAfter = JsonConvert.SerializeObject(subscription);
- }
- else
- {
- StateBefore = JsonConvert.SerializeObject(subscription);
- StateAfter = "{}";
- }
- Subscription = subscription;
- }
-
- public void SetAfter(Subscription stateAfter)
- {
- StateAfter = JsonConvert.SerializeObject(stateAfter);
- }
-
- public int SubscriptionId { get; private set; }
- public Subscription Subscription { get; private set; }
-
- public SubscriptionTrialCode Code { get; private set; }
-
- public string StateBefore { get; private set; }
-
- public string StateAfter { get; private set; }
-
- public DateTime CreatedOn { get; set; }
- public string CreatedBy { get; set; }
-}
\ No newline at end of file
diff --git a/SW.Bitween.Api/Domain/Subscription/SubscriptionTrailCode.cs b/SW.Bitween.Api/Domain/Subscription/SubscriptionTrailCode.cs
deleted file mode 100644
index 33e58f76..00000000
--- a/SW.Bitween.Api/Domain/Subscription/SubscriptionTrailCode.cs
+++ /dev/null
@@ -1,9 +0,0 @@
-namespace SW.Bitween.Domain;
-
-public enum SubscriptionTrialCode
-{
- Created = 11,
- Updated = 21,
- Paused = 31,
- Resumed = 32,
-}
\ No newline at end of file
diff --git a/SW.Bitween.Api/Resources/Audit/Search.cs b/SW.Bitween.Api/Resources/Audit/Search.cs
new file mode 100644
index 00000000..55f021b4
--- /dev/null
+++ b/SW.Bitween.Api/Resources/Audit/Search.cs
@@ -0,0 +1,107 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using Microsoft.EntityFrameworkCore;
+using Newtonsoft.Json;
+using SW.Bitween.Domain;
+using SW.Bitween.Domain.Accounts;
+using SW.Bitween.Model;
+using SW.PrimitiveTypes;
+
+namespace SW.Bitween.Resources.Audit;
+
+///
+/// The audit trail, newest first. Serves both the global history page and the per-entity timeline —
+/// the latter is this same query with and
+/// set, which is what the composite index covers.
+///
+public class Search(BitweenDbContext dbContext, RequestContext requestContext)
+ : IQueryHandler
+{
+ /// Rows per page ceiling — each row carries its changes as JSON.
+ const int MaxLimit = 200;
+
+ public async Task