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
4 changes: 3 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,9 @@ Every operation that changes data runs inside a unit of work and appends the aud

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.

`IAuditReader.Read(AuditQuery)` reads the log back: an optional window from `From` (inclusive) to `To` (exclusive) and an optional `AuditSubject` for one item's history, oldest first with ties broken by id, streamed from the database. With no window and no subject it is the full log. An inverted window or an incomplete subject is a failed `Result`. `IAuditExporter.ExportAsync` writes the same selection to a stream as JSON or CSV while it reads it. Both formats carry the same fields under the same names, in this order: `id`, `timestamp`, `operation`, `initiatorKind` (`user` or `agent`), `initiatorId`, `initiatorName`, `agentCredentialId`, `subjectType`, `subjectId`, `model`, `promptHash`, `sources`, `changeSetId` and `previousVersionId`. The JSON is an array of objects with every key present and `null` for an empty field. The CSV follows RFC 4180 with a header row and CRLF line ends, plus three conventions so every value reads back exactly: `sources` is one cell holding a JSON array, an unquoted empty cell is null while a quoted one is an empty string, and a value starting with `=`, `+`, `-`, `@`, a tab, a carriage return or `'` gets one leading `'`, so a spreadsheet does not run it as a formula; a reader drops that one `'`.
`IAuditReader.Read(AuditQuery)` reads the log back: an optional window from `From` (inclusive) to `To` (exclusive) and an optional `AuditSubject` for one item's history, oldest first with ties broken by id, streamed from the database. With no window and no subject it is the full log. An inverted window or an incomplete subject is a failed `Result`. `IAuditExporter.ExportAsync` writes the same selection to a stream as JSON or CSV while it reads it, after committing an `audit.exported` event that names who exported and, for one item's history, the item; viewing history is a read and records nothing. Both formats carry the same fields under the same names, in this order: `id`, `timestamp`, `operation`, `initiatorKind` (`user` or `agent`), `initiatorId`, `initiatorName`, `agentCredentialId`, `subjectType`, `subjectId`, `model`, `promptHash`, `sources`, `changeSetId` and `previousVersionId`. The JSON is an array of objects with every key present and `null` for an empty field. The CSV follows RFC 4180 with a header row and CRLF line ends, plus three conventions so every value reads back exactly: `sources` is one cell holding a JSON array, an unquoted empty cell is null while a quoted one is an empty string, and a value starting with `=`, `+`, `-`, `@`, a tab, a carriage return or `'` gets one leading `'`, so a spreadsheet does not run it as a formula; a reader drops that one `'`.

The admin's `/audit` page offers the export and an item's history. A page cannot answer with a file, so its export form posts to `POST /audit/export` in the host, which maps the form, calls the exporter and streams the result; it needs a signed-in user and an anti-forgery token like every form, and sends a rejected form back to the page.

## Extension points

Expand Down
6 changes: 5 additions & 1 deletion src/FluentCMS.Admin/AdminRoutes.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
namespace FluentCMS.Admin;

// The account routes the host needs for the cookie's redirects; each matches the @page directive of its page.
// The routes the host needs: the account pages for the cookie's redirects, and the audit page with the download it posts to.
// Each page route matches the @page directive of its page.
public static class AdminRoutes
{
public const string Setup = "/setup";
Expand All @@ -11,6 +12,9 @@ public static class AdminRoutes
// Not a page: the host's endpoint that sends the browser to the external provider.
public const string ExternalSignInStart = "/sign-in/external/start";

public const string Audit = "/audit";
public const string AuditExport = "/audit/export";

// Only a path on this site is followed after sign-in, so a crafted link cannot send someone elsewhere.
public static bool IsLocal(string? url)
{
Expand Down
146 changes: 146 additions & 0 deletions src/FluentCMS.Admin/Audit/AuditExportForm.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
using System.Globalization;
using FluentCMS.Core.Audit;
using FluentCMS.Core.Results;

namespace FluentCMS.Admin.Audit;

public sealed record AuditExportRequest(AuditQuery Query, AuditExportFormat Format);

// The export form as the audit page posts it to the host's download endpoint, which sends a rejected one back with ReturnQuery.
public sealed class AuditExportForm
{
public const string FromInvalid = "audit_from_invalid";
public const string ToInvalid = "audit_to_invalid";
public const string SubjectIdInvalid = "audit_subject_id_invalid";

// The query names the page reads a rejected form back from.
public const string FromName = "from";
public const string ToName = "to";
public const string SubjectTypeName = "subjectType";
public const string SubjectIdName = "subjectId";
public const string ErrorName = "error";

// UTC dates as a date input sends them, both included.
public string? From { get; set; }

public string? To { get; set; }

public string? SubjectType { get; set; }

public string? SubjectId { get; set; }

public string? Format { get; set; }

public Result<AuditExportRequest> ToRequest()
{
var errors = new List<Error>();

var from = ReadDate(From, FromInvalid, errors);
var to = ReadDate(To, ToInvalid, errors);
if (from > to)
{
errors.Add(new Error(AuditErrorCodes.WindowInverted, "The end date is before the start date."));
}

var subject = ReadSubject(errors);
var format = ReadFormat(errors);

if (errors.Count > 0)
{
return Result<AuditExportRequest>.Failure(errors);
}

var query = new AuditQuery
{
From = from is { } start ? Midnight(start) : null,
To = to is { } end && end < DateOnly.MaxValue ? Midnight(end.AddDays(1)) : null,
Subject = subject,
};

var valid = query.Validate();
return valid.IsSuccess
? Result<AuditExportRequest>.Success(new AuditExportRequest(query, format!.Value))
: Result<AuditExportRequest>.Failure(valid.Errors);
}

public IEnumerable<KeyValuePair<string, string?>> ReturnQuery(IEnumerable<Error> errors)
{
(string Name, string? Value)[] fields =
[
(FromName, From),
(ToName, To),
(SubjectTypeName, SubjectType),
(SubjectIdName, SubjectId),
];

return fields
.Where(field => !string.IsNullOrWhiteSpace(field.Value))
.Select(field => KeyValuePair.Create(field.Name, field.Value))
.Concat(errors.Select(error => error.Code).Distinct().Select(code => KeyValuePair.Create(ErrorName, (string?)code)));
}

private static DateOnly? ReadDate(string? text, string code, List<Error> errors)
{
if (string.IsNullOrWhiteSpace(text))
{
return null;
}

if (DateOnly.TryParseExact(text.Trim(), "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var date))
{
return date;
}

errors.Add(new Error(code, $"'{text}' is not a date in the form YYYY-MM-DD."));
return null;
}

private AuditSubject? ReadSubject(List<Error> errors)
{
var type = SubjectType?.Trim();
var id = SubjectId?.Trim();

if (string.IsNullOrEmpty(type) && string.IsNullOrEmpty(id))
{
return null;
}

if (string.IsNullOrEmpty(type))
{
errors.Add(new Error(AuditErrorCodes.SubjectTypeMissing, "An item's history needs the item's type."));
}

if (string.IsNullOrEmpty(id))
{
errors.Add(new Error(AuditErrorCodes.SubjectIdMissing, "An item's history needs the item's id."));
return null;
}

if (!Guid.TryParse(id, out var subjectId))
{
errors.Add(new Error(SubjectIdInvalid, $"'{id}' is not an item id."));
return null;
}

return new AuditSubject(type ?? string.Empty, subjectId);
}

private AuditExportFormat? ReadFormat(List<Error> errors)
{
switch (Format?.Trim().ToLowerInvariant())
{
case "json":
return AuditExportFormat.Json;
case "csv":
return AuditExportFormat.Csv;
default:
errors.Add(new Error(AuditErrorCodes.ExportFormatUnknown, "Choose JSON or CSV."));
return null;
}
}

private static DateTimeOffset Midnight(DateOnly date)
{
return new DateTimeOffset(date.ToDateTime(TimeOnly.MinValue), TimeSpan.Zero);
}
}
1 change: 1 addition & 0 deletions src/FluentCMS.Admin/Components/Layout/MainLayout.razor
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
<a href="" class="font-semibold">FluentCMS</a>
@if (Initiator.Current is UserInitiator user)
{
<a href="audit" class="text-sm text-muted-foreground hover:text-foreground">@L["Audit log"]</a>
@* A plain form, so it posts to the sign-out page's handler from any page, static or interactive. *@
<form method="post" action="sign-out" class="ms-auto flex items-center gap-3">
<AntiforgeryToken />
Expand Down
Loading
Loading