Skip to content

Commit

Permalink
feat: add audit logging (#327)
Browse files Browse the repository at this point in the history
fix: #332 bindToQueryString is not immediate in vue3
  • Loading branch information
ascott18 authored Oct 30, 2023
1 parent 9cd153e commit 7968f4f
Show file tree
Hide file tree
Showing 95 changed files with 7,489 additions and 215 deletions.
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ publish/
# Publish Web Output
*.[Pp]ublish.xml
*.azurePubxml
# TODO: Comment the next line if you want to checkin your web deploy settings
# Comment the next line if you want to checkin your web deploy settings
# but database connection strings (with potential passwords) will be unencrypted
*.pubxml
*.publishproj
Expand Down
14 changes: 14 additions & 0 deletions Coalesce.sln
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Coalesce.Web.Vue2", "playgr
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "IntelliTect.Coalesce.Swashbuckle.Tests", "src\IntelliTect.Coalesce.Swashbuckle.Tests\IntelliTect.Coalesce.Swashbuckle.Tests.csproj", "{D5B01884-1B53-4C5D-AE66-04F3B0795FA8}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "IntelliTect.Coalesce.AuditLogging", "src\IntelliTect.Coalesce.AuditLogging\IntelliTect.Coalesce.AuditLogging.csproj", "{0CCC382C-C626-4C42-905A-7AC1963A4E6B}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "IntelliTect.Coalesce.AuditLogging.Tests", "src\IntelliTect.Coalesce.AuditLogging.Tests\IntelliTect.Coalesce.AuditLogging.Tests.csproj", "{0808FD33-DA45-4270-82F3-3AE6C66F1D0A}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -140,6 +144,14 @@ Global
{D5B01884-1B53-4C5D-AE66-04F3B0795FA8}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D5B01884-1B53-4C5D-AE66-04F3B0795FA8}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D5B01884-1B53-4C5D-AE66-04F3B0795FA8}.Release|Any CPU.Build.0 = Release|Any CPU
{0CCC382C-C626-4C42-905A-7AC1963A4E6B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{0CCC382C-C626-4C42-905A-7AC1963A4E6B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{0CCC382C-C626-4C42-905A-7AC1963A4E6B}.Release|Any CPU.ActiveCfg = Release|Any CPU
{0CCC382C-C626-4C42-905A-7AC1963A4E6B}.Release|Any CPU.Build.0 = Release|Any CPU
{0808FD33-DA45-4270-82F3-3AE6C66F1D0A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{0808FD33-DA45-4270-82F3-3AE6C66F1D0A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{0808FD33-DA45-4270-82F3-3AE6C66F1D0A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{0808FD33-DA45-4270-82F3-3AE6C66F1D0A}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand All @@ -162,6 +174,8 @@ Global
{E4CA7A20-C771-4F64-98E8-90495F1089CA} = {AC58582C-916B-432E-B379-0FD58A663BF1}
{A12234E0-DF1F-4042-8CAF-D57F9CED37C0} = {AC58582C-916B-432E-B379-0FD58A663BF1}
{D5B01884-1B53-4C5D-AE66-04F3B0795FA8} = {CCBE3CC2-7DA3-4BA1-96A1-7BAAF8533D58}
{0CCC382C-C626-4C42-905A-7AC1963A4E6B} = {18165BA2-31FA-4D8E-94BE-AAD008D6BCEB}
{0808FD33-DA45-4270-82F3-3AE6C66F1D0A} = {CCBE3CC2-7DA3-4BA1-96A1-7BAAF8533D58}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {00AED75F-9188-45D1-9D31-5486D4464AF2}
Expand Down
2 changes: 1 addition & 1 deletion docs/.vuepress/components/Prop.vue
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ export default defineComponent({
// Typescript is pretty nice in that the member name is always first in its declaration.
// No types on the left hand side.
// "namespace" is included as part of the attr if present.
const result = /(?:(?:readonly|public|static|protected|private|abstract|export) )*((?:namespace )?[\w$]+)/.exec(this.def);
const result = /(?:(?:readonly|public|static|protected|private|abstract|export) )*((?:namespace )?[\w$-]+)/.exec(this.def);
this.idAttr = result ? this.idPrefix + '-' + result[1] : null
} else if (this.lang == "c#") {
// For C#, there's always a type on the left hand side.
Expand Down
17 changes: 6 additions & 11 deletions docs/.vuepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,8 +107,6 @@ export default defineUserConfig({
{text: 'Models', link: '/stacks/vue/layers/models' },
{text: 'API Clients', link: '/stacks/vue/layers/api-clients' },
{text: 'View Models', link: '/stacks/vue/layers/viewmodels' },

{text: 'Vue 2 to Vue 3', link: '/stacks/vue/vue2-to-vue3' },
],
},
{
Expand All @@ -125,19 +123,16 @@ export default defineUserConfig({
]
},
{
text: 'Concepts',
// collapsible: false,
children: [
'/concepts/include-tree',
'/concepts/includes',
],
},
{
text: 'Configuration',
text: 'Topics',
// collapsible: false,
children: [
'/topics/startup',
'/topics/audit-logging',
'/topics/coalesce-json',
'/concepts/include-tree',
'/concepts/includes',

{text: 'Vue 2 to Vue 3', link: '/stacks/vue/vue2-to-vue3' },
],
},
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# c-admin-audit-log-page

<!-- MARKER:summary -->

A full-featured page for interacting with Coalesce's [Audit Logging](/topics/audit-logging.md). Presents a view similar to [c-admin-table-page](/stacks/vue/coalesce-vue-vuetify/components/c-admin-table-page.md) with content optimized for viewing audit log records. Designed to be routed to directly with [vue-router](https://router.vuejs.org/).

<!-- MARKER:summary-end -->

## Examples

``` ts
import { CAdminAuditLogPage } from 'coalesce-vue-vuetify3';
const router = new Router({
// ...
routes: [
// ... other routes
{
path: '/admin/audit-logs',
component: CAdminAuditLogPage,
props: {
type: 'AuditLog'
}
},
]
})
```

## Props

<Prop def="type: string" lang="ts" />

The PascalCase name of your `IAuditLog` implementation.

<Prop def="list?: ListViewModel" lang="ts" />

An optional [ListViewModel](/stacks/vue/layers/viewmodels.md) that will be used if provided instead of the one the component will create automatically from the provided `type` prop.

<Prop def="color: string" lang="ts" />

A Vuetify color name to be applied to the toolbar at the top of the page.


## Slots

<Prop def="row-detail: { item: AuditLogViewModel }" lang="ts" />

A slot that can be used to replace the entire content of the Detail column on the page.

<Prop def="row-detail-append: { item: AuditLogViewModel }" lang="ts" />

A slot that can be used to append additional content to the Detail column on the page.
7 changes: 7 additions & 0 deletions docs/stacks/vue/coalesce-vue-vuetify/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -212,5 +212,12 @@ If for whatever reason you find yourself adding Coalesce to an existing project,
<td>

@[import-md "after":"MARKER:summary", "before":"MARKER:summary-end"](./components/c-admin-table-page.md)
</td></tr><tr><td>

[c-admin-audit-log-page](./components/c-admin-audit-log-page.md)
</td>
<td>

@[import-md "after":"MARKER:summary", "before":"MARKER:summary-end"](./components/c-admin-audit-log-page.md)
</td></tr>
</table>
2 changes: 1 addition & 1 deletion docs/stacks/vue/layers/viewmodels.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ By default, only dirty properties (and always the primary key) are sent to the s

This improves the handling of concurrent changes being made by multiple users against different fields of the same entity at the same time - specifically, it prevents a user with a stale value of some field X from overwriting a more recent value of X in the database when the user is only making changes to some other property Y and has no intention of changing X.

Save mode `"surgical"` doesn't help when multiple users are editing field X at the same time - if such a scenario is applicable to your application, you must implement [more advanced handling of concurrency conflicts](https://docs.microsoft.com/en-us/ef/core/saving/concurrency).
Save mode `"surgical"` doesn't help when multiple users are editing field X at the same time - if such a scenario is applicable to your application, you must implement [more advanced handling of concurrency conflicts](https://learn.microsoft.com/en-us/ef/core/saving/concurrency).

::: warning
@[import-md "after":"MARKER:surgical-saves-warning", "before":"MARKER:end-surgical-saves-warning"](../../../modeling/model-types/dtos.md)
Expand Down
160 changes: 160 additions & 0 deletions docs/topics/audit-logging.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
# Audit Logging

Keeping a history of all (or most) of the changes that are made to records in your database can be invaluable, both for [non-repudiation](https://csrc.nist.gov/glossary/term/non_repudiation) (i.e. proving what happened and who did it), and for troubleshooting or debugging.

Coalesce provides a package `IntelliTect.Coalesce.AuditLogging` that adds an easy way to inject this kind of audit logging into your EF Core `DbContext`. It also includes an out-of-the-box view [`c-admin-audit-log-page`](/stacks/vue/coalesce-vue-vuetify/components/c-admin-audit-log-page.md) that enables browsing of this data on the frontend.

## Setup

In this setup process, we're going to add an additional Coalesce Nuget package, define a custom entity to hold our audit logs and any extra properties, install the audit logging extension into our `DbContext`, and add a pre-made interface on the frontend to view our logs.

### 1. Add the NuGet package

Add a reference to the Nuget package `IntelliTect.Coalesce.AuditLogging` to your data project:

``` xml:no-line-numbers{3}
<ItemGroup>
<PackageReference Include="IntelliTect.Coalesce.Vue" Version="$(CoalesceVersion)" />
<PackageReference Include="IntelliTect.Coalesce.AuditLogging" Version="$(CoalesceVersion)" />
</ItemGroup>
```

### 2. Define the log entity

Define the entity type that will hold the audit records in your database:

``` c#
using IntelliTect.Coalesce.AuditLogging;

[Read(Roles = "Administrator")]
public class AuditLog : DefaultAuditLog
{
public string? UserId { get; set; }
public AppUser? User { get; set; }

// Other custom props as desired
}
```

This entity only needs to implement `IAuditLog`, but a default implementation of this interface `DefaultAuditLog` is provided for your convenience. `DefaultAuditLog` contains additional properties `ClientIp`, `Referrer`, and `Endpoint` for recording information about the HTTP request (if available), and also has attributes to disable Create, Edit, and Delete APIs.

You should further augment this type with any additional properties that you would like to track on each change record. A property to track the user who performed the change should be added, since it is not provided by the default implementation so that you can declare it yourself with the correct type for the foreign key and navigation property.

You should also apply security to restrict reading of these records to only the most privileged users with a [Read Attribute](/modeling/model-components/attributes/security-attribute.md#read) (as in the example above) and/or a [custom Default Data Source](/modeling/model-components/data-sources.md#defining-data-sources).

### 3. Configure your `DbContext`

On your `DbContext`, implement the `IAuditLogDbContext<AuditLog>` interface using the class you just created as the type parameter. Then register the Coalesce audit logging extension in your `DbContext`'s `OnConfiguring` method so that saves will be intercepted and audit log entries created.

``` c#
[Coalesce]
public class AppDbContext : DbContext, IAuditLogDbContext<AuditLog>
{
public DbSet<AuditLog> AuditLogs { get; set; }
public DbSet<AuditLogProperty> AuditLogProperties { get; set; }

protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder.UseCoalesceAuditLogging<AuditLog>(x => x
.WithAugmentation<OperationContext>()
);
}
}
```

You could also perform this setup in your web project when calling `.AddDbContext()`.

The above code also contains a reference to a class `OperationContext`. This is the service that will allow you to populate additional custom properties on your audit entries. You'll want to define it as follows:

``` c#
public class OperationContext : DefaultAuditOperationContext<AuditLog>
{
// Inject any additional desired services in the constructor:
public OperationContext(IHttpContextAccessor accessor) : base(accessor) { }

public override void Populate(AuditLog auditEntry, EntityEntry changedEntity)
{
base.Populate(auditEntry, changedEntity);

// Adjust as needed to retrieve your UserId from the ClaimsPrincipal.
auditEntry.UserId = User.GetUserId();
}
}
```

When you're inheriting from `DefaultAuditLog` for your `IAuditLog` implementation, you'll want to similarly inherit from `DefaultAuditOperationContext<>` for your operation context. It will take care of populating the HTTP request tracking fields on the `AuditLog` record. If you want a totally custom implementation, you only need to implement the `IAuditOperationContext<TAuditLog>` interface.

The operation context class passed to `WithAugmentation` will be injected from the application service provider if available; otherwise, a new instance will be constructed using dependencies from the application service provider. To make an injected dependency optional, make the constructor parameter nullable with a default value of `null`, or create [alternate constructors](https://learn.microsoft.com/en-us/dotnet/core/extensions/dependency-injection#multiple-constructor-discovery-rules).

### 4. Add the UI

For Vue applications, the [c-admin-audit-log-page](/stacks/vue/coalesce-vue-vuetify/components/c-admin-audit-log-page.md) component provides an out-of-the-box user interface for browsing through audit logs. Simply define the following route in your application's router:

``` ts
import { CAdminAuditLogPage } from 'coalesce-vue-vuetify3';

{
path: '/admin/audit-logs',
component: CAdminAuditLogPage,
props: { type: 'AuditLog' }
}
```

## Configuration

### Suppression

You can turn audit logging on or off for individual operations by implementing the `SuppressAudit` property on your DbContext. For example, implement it as an auto-property as follows and then set it to `true` in application code when desired:

``` c#
[Coalesce]
public class AppDbContext : DbContext, IAuditLogDbContext<AuditLog>
{
...
public bool SuppressAudit { get; set; }
}
```

### Exclusions & Formatting

Coalesce's audit logging is built on top of [Entity Framework Plus](https://entityframework-plus.net/ef-core-audit) and can be configured using all of its [configuration](https://entityframework-plus.net/ef-core-audit#scenarios), including [includes/excludes](https://entityframework-plus.net/ef-core-audit-exclude-include-entity) and [custom property formatting](https://entityframework-plus.net/ef-core-audit-format-value).

Coalesce will not use EF Plus's `AuditManager.DefaultConfiguration` global singleton instance. You must use Coalesce's configuration extensions which allow for more targeted configuration per context that does not rely on a global static singleton. For example:

``` c#
public class AppDbContext : DbContext, IAuditLogDbContext<AuditLog>
{
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder.UseCoalesceAuditLogging<AuditLog>(x => x
.WithAugmentation<OperationContext>()
.ConfigureAudit(c => c
.Exclude<DataProtectionKey>()
.ExcludeProperty<TrackingBase>(x => new { x.CreatedById, x.CreatedOn, x.ModifiedById, x.ModifiedOn })
.FormatType<DateTimeOffset>(d => d.ToTimeZone("America/Los_Angeles").ToString())
.Format<Image>(x => x.Content, x => $"{Convert.ToHexString(SHA1.HashData(x))}, {x.Length} bytes")
)
);
}
}
```

### Property Descriptions

The `AuditLogProperty` children of your `IAuditLog` implementation have two properties `OldValueDescription` and `NewValueDescription` that can be used to hold a description of the old and new values. By default, Coalesce will populate the descriptions of foreign key properties with the [List Text](/modeling/model-components/attributes/list-text.md) of the referenced principal entity. This greatly improves the usability of the audit logs, which would otherwise only show meaningless numbers or GUIDs for foreign keys that changed.

This feature will load principal entities into the `DbContext` if they are not already loaded, which could inflict subtle differences in application functionality in rare edge cases if your application is making assumptions about navigation properties not being loaded. Typically though, this will not be an issue and will not lead unintentional information disclosure to clients as along as [IncludeTree](/concepts/include-tree.md)s are used correctly.

This feature may be disabled by calling `.WithPropertyDescriptions(PropertyDescriptionMode.None)` inside your call to `.UseCoalesceAuditLogging(...)` in your DbContext configuration. You may also populate these descriptions in your `IAuditOperationContext` implementation that was provided to `.WithAugmentation<T>()`.


## Merging
When using a supported database provider (currently only SQL Server), audit records for changes to the same entity will be merged together when the change is identical in all aspects to the previous audit record for that entity, with the sole exception of the old/new property values.

In other words, if the same user is making repeated changes to the same property on the same entity from the same page, then those changes will merge together into one audit record.

This merging only happens together if the existing audit record is recent; the default cutoff for this is 30 seconds, but can be configured with `.WithMergeWindow(TimeSpan.FromSeconds(15))` when calling `UseCoalesceAuditLogging`. It can also be turned off by setting this value to `TimeSpan.Zero`. The merging logic respects all custom properties you add to your `IAuditLog` implementation, requiring their values to match between the existing and new audit records for a merge to occur.

## Caveats
Only changes that are tracked by the `DbContext`'s `ChangeTracker` can be audited. Changes that are made with raw SQL, or changes that are made with bulk update functions like [`ExecuteUpdate` or `ExecuteDelete`](https://learn.microsoft.com/en-us/ef/core/performance/efficient-updating?tabs=ef7) will not be audited using this package.

27 changes: 24 additions & 3 deletions playground/Coalesce.Domain/AppDbContext.cs
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
using IntelliTect.Coalesce;
using IntelliTect.Coalesce.AuditLogging;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking;
using Microsoft.Extensions.DependencyInjection;
using System;
using System.Linq;

namespace Coalesce.Domain
{

[Coalesce]
public class AppDbContext : DbContext
public class AppDbContext : DbContext, IAuditLogDbContext<AuditLog>
{
#nullable disable
public DbSet<Person> People { get; set; }
Expand All @@ -15,6 +21,10 @@ public class AppDbContext : DbContext
public DbSet<CaseProduct> CaseProducts { get; set; }
public DbSet<ZipCode> ZipCodes { get; set; }
public DbSet<Log> Logs { get; set; }

public DbSet<AuditLog> AuditLogs { get; set; }
public DbSet<AuditLogProperty> AuditLogProperties { get; set; }

#nullable restore

public AppDbContext()
Expand All @@ -25,10 +35,21 @@ public AppDbContext(DbContextOptions options) : base(options)
{
}

protected override void OnModelCreating(ModelBuilder modelBuilder)
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
base.OnModelCreating(modelBuilder);
optionsBuilder
.UseCoalesceAuditLogging<AuditLog>(x => x
.WithAugmentation<OperationContext>()
.WithMergeWindow(TimeSpan.FromSeconds(15))
.ConfigureAudit(x => x
// Just a random example of EFPlus config:
.ExcludeProperty<Person>(p => p.ProfilePic)
)
);
}

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Product>().OwnsOne(p => p.Details, cb =>
{
cb.OwnsOne(c => c.ManufacturingAddress);
Expand Down
Loading

0 comments on commit 7968f4f

Please sign in to comment.