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
2 changes: 1 addition & 1 deletion src/FluentCMS.Admin/Components/Layout/MainLayout.razor
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
<a href="" class="font-semibold">FluentCMS</a>
@if (Initiator.Current is UserInitiator user)
{
<a href="" class="text-sm text-muted-foreground hover:text-foreground">@L["Desk"]</a>
<NotificationLinks />
<a href="audit" class="text-sm text-muted-foreground hover:text-foreground">@L["Audit log"]</a>
<a href="users" class="text-sm text-muted-foreground hover:text-foreground">@L["Users"]</a>
<a href="agents" class="text-sm text-muted-foreground hover:text-foreground">@L["Agents"]</a>
Expand Down
43 changes: 43 additions & 0 deletions src/FluentCMS.Admin/Components/Layout/NotificationLinks.razor
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
@using System.Globalization
@using FluentCMS.Core.Audit
@using FluentCMS.Core.Desk
@inject IDeskQueues Queues
@inject IAuditEventLists AuditLists
@inject TimeProvider Clock
@inject IStringLocalizer<NotificationLinks> L

@* The in-app notification counts (DSK-05), read on each page load: sets awaiting the person, and security events of the last day. *@
<a href="" class="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground" data-testid="nav-desk">
@L["Desk"]
@if (_awaiting is > 0)
{
<Badge Class="tabular-nums" data-count="@_awaiting">@_awaiting.Value.ToString(CultureInfo.CurrentCulture)<span class="sr-only"> @L["awaiting you"]</span></Badge>
}
</a>
<a href="security" class="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground" data-testid="nav-security">
@L["Security"]
@if (_security is > 0)
{
<Badge Variant="secondary" Class="tabular-nums" data-count="@_security">@_security.Value.ToString(CultureInfo.CurrentCulture)<span class="sr-only"> @L["in the last day"]</span></Badge>
}
</a>

@code {
public static readonly TimeSpan SecurityWithin = TimeSpan.FromDays(1);

private int? _awaiting;
private int? _security;

protected override async Task OnInitializedAsync()
{
var awaiting = await Queues.CountAsync(DeskQueue.AwaitingMe);
_awaiting = awaiting.IsSuccess ? awaiting.Value : null;

var security = await AuditLists.CountAsync(new AuditEventListQuery
{
Operations = SecurityEvents.Operations,
Since = Clock.GetUtcNow() - SecurityWithin,
});
_security = security.IsSuccess ? security.Value : null;
}
}
5 changes: 4 additions & 1 deletion src/FluentCMS.Admin/Components/Pages/Desk.razor
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@
<h1 class="text-2xl font-semibold tracking-tight">@L["Desk"]</h1>
<p class="mt-1 text-sm text-muted-foreground">@L["Change sets and facts that are waiting for a person."]</p>
</div>
<ShortcutHelp Shortcuts="Shortcuts" />
<span class="flex items-center gap-2">
<a href="security" class="@ButtonVariants.Classes("ghost", "sm")" data-testid="desk-security">@L["Security events"]</a>
<ShortcutHelp Shortcuts="Shortcuts" />
</span>
</div>
@if (DoneMessage is { } done)
{
Expand Down
148 changes: 148 additions & 0 deletions src/FluentCMS.Admin/Components/Pages/SecurityEventsView.razor
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
@page "/security"
@using System.Globalization
@using FluentCMS.Core.Audit
@inject IAuditEventLists AuditLists
@inject IStringLocalizer<SecurityEventsView> L

<PageTitle>@L["Security events"]</PageTitle>

<h1 id="security-heading" class="text-2xl font-semibold tracking-tight">@L["Security events"]</h1>
<p class="mt-1 text-sm text-muted-foreground">@L["Agent keys issued and revoked, caps exceeded, refused moves, changes to people's accounts and to routing rules, the newest first. Every one is also in the audit log."]</p>

@if (_error is not null)
{
<p role="alert" class="mt-4 text-sm text-destructive" data-testid="security-error">@_error</p>
}
else if (_total == 0)
{
<p class="mt-6 rounded-md border px-3 py-6 text-center text-sm text-muted-foreground" data-testid="security-empty">@L["No security event is recorded."]</p>
}
else if (_events.Count == 0)
{
<p class="mt-6 rounded-md border px-3 py-6 text-center text-sm text-muted-foreground" data-testid="security-past-end">
@L["This page is past the end of the list."]
<a href="security" class="underline underline-offset-4">@L["Go to the first page"]</a>
</p>
}
else
{
<div class="mt-6 rounded-md border">
<Table aria-labelledby="security-heading">
<TableHeader>
<TableRow>
<TableHead>@L["When"]</TableHead>
<TableHead>@L["What"]</TableHead>
<TableHead>@L["Who"]</TableHead>
<TableHead>@L["About"]</TableHead>
</TableRow>
</TableHeader>
<TableBody data-ui="rove">
@foreach (var (auditEvent, index) in _events.Select((auditEvent, index) => (auditEvent, index)))
{
<TableRow data-event="@auditEvent.Operation">
<TableCell>
<time datetime="@auditEvent.Timestamp.ToString("O", CultureInfo.InvariantCulture)" dir="ltr" class="text-muted-foreground">@auditEvent.Timestamp.UtcDateTime.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture) UTC</time>
</TableCell>
<TableCell Class="whitespace-normal font-medium">@Describe(auditEvent.Operation)</TableCell>
<TableCell>
<span class="flex items-center gap-2">
@switch (auditEvent.Initiator)
{
case AgentInitiator:
<Badge data-initiator="agent">@L["Agent"]</Badge>
break;
case SystemInitiator:
<Badge Variant="outline" data-initiator="system">@L["System"]</Badge>
break;
default:
<Badge Variant="secondary" data-initiator="user">@L["User"]</Badge>
break;
}
<bdi>@auditEvent.Initiator.DisplayName</bdi>
</span>
</TableCell>
<TableCell>
@if (auditEvent.SubjectType is { } type && auditEvent.SubjectId is { } id)
{
<a href="@($"audit?type={Uri.EscapeDataString(type)}&id={id}")" data-ui="rove-item" tabindex="@(index == 0 ? null : "-1")" class="underline-offset-4 hover:underline">
<bdi>@type</bdi> <bdi class="font-mono text-xs text-muted-foreground" dir="ltr">@id.ToString("N")[..8]</bdi>
</a>
}
</TableCell>
</TableRow>
}
</TableBody>
</Table>
</div>

<nav class="mt-4 flex flex-wrap items-center justify-between gap-4" aria-label="@L["Pages"]" data-testid="security-pages">
<p class="text-sm text-muted-foreground">@L["{0} to {1} of {2}", _offset + 1, _offset + _events.Count, _total]</p>
<span class="flex gap-2">
@if (_page > 1)
{
<a href="@Href(_page - 1)" rel="prev" class="@ButtonVariants.Classes("outline", "sm")">@L["Previous"]</a>
}
@if (_offset + PageSize < _total)
{
<a href="@Href(_page + 1)" rel="next" class="@ButtonVariants.Classes("outline", "sm")">@L["Next"]</a>
}
</span>
</nav>
}

@code {
public const int PageSize = 50;

private IReadOnlyList<AuditEvent> _events = [];
private int _total;
private int _page;
private int _offset;
private string? _error;

[SupplyParameterFromQuery(Name = "page")]
private int? PageNumber { get; set; }

protected override async Task OnInitializedAsync()
{
_page = Math.Max(1, PageNumber ?? 1);
_offset = (int)Math.Min((_page - 1L) * PageSize, int.MaxValue - PageSize);

var listed = await AuditLists.ListAsync(new AuditEventListQuery { Operations = SecurityEvents.Operations, Offset = _offset, Limit = PageSize });
if (listed.IsSuccess)
{
(_events, _total) = (listed.Value.Items, listed.Value.Total);
}
else
{
// The error code is the resource key; the reader's own message is the text until a resource exists.
_error = string.Join(" ", listed.Errors.Select(error =>
{
var localized = L[error.Code];
return localized.ResourceNotFound ? error.Message : localized.Value;
}));
}
}

private static string Href(int page) => page > 1 ? $"security?page={page}" : "security";

private string Describe(string operation) => operation switch
{
"agent_credential.issued" => L["An agent key was issued"],
"agent_credential.revoked" => L["An agent key was revoked"],
"agent.calls_per_period_exceeded" => L["An agent went over its calls cap"],
"agent.change_sets_per_day_exceeded" => L["An agent went over its change sets per day"],
"agent.items_per_change_set_exceeded" => L["An agent went over its items per change set"],
"agent.transition_refused" => L["An agent tried a move only people make"],
"user.created" => L["A person was added"],
"user.disabled" => L["A person was disabled"],
"user.enabled" => L["A person was enabled again"],
"user.password_reset_issued" => L["A password reset link was issued"],
"user.password_reset" => L["A password was reset"],
"user.login_linked" => L["An external sign-in was linked"],
"routing_rule.created" => L["A routing rule was created"],
"routing_rule.revised" => L["A routing rule was revised"],
"routing_rule.activated" => L["A routing rule was activated"],
"routing_rule.deactivated" => L["A routing rule was deactivated"],
_ => operation,
};
}
5 changes: 5 additions & 0 deletions src/FluentCMS.Core/Audit/AuditErrorCodes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,9 @@ public static class AuditErrorCodes
public const string SubjectTypeMissing = "audit_subject_type_missing";
public const string SubjectIdMissing = "audit_subject_id_missing";
public const string ExportFormatUnknown = "audit_export_format_unknown";
public const string OperationsMissing = "audit_operations_missing";

// The same codes the other lists of the desk use for a window out of range.
public const string OffsetInvalid = "offset_invalid";
public const string LimitInvalid = "limit_invalid";
}
51 changes: 51 additions & 0 deletions src/FluentCMS.Core/Audit/IAuditEventLists.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
using FluentCMS.Core.Results;

namespace FluentCMS.Core.Audit;

// Pages of the log for screens, newest first, such as the desk's security events (SEC-08). IAuditReader streams the log oldest first
// for exports; this reads one page and the total. A list appends no event.
public interface IAuditEventLists
{
Task<Result<AuditEventList>> ListAsync(AuditEventListQuery query, CancellationToken cancellationToken = default);

// How many events the query selects, whatever its offset and limit.
Task<Result<int>> CountAsync(AuditEventListQuery query, CancellationToken cancellationToken = default);
}

public sealed record AuditEventList(IReadOnlyList<AuditEvent> Items, int Total);

// The events with any of the given operations, optionally only those at or after a time, newest first.
public sealed record AuditEventListQuery
{
public const int MaxLimit = 200;

public required IReadOnlyCollection<string> Operations { get; init; }

public DateTimeOffset? Since { get; init; }

public int Offset { get; init; }

public int Limit { get; init; } = 50;

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

if (Operations is null || Operations.Count == 0 || Operations.Any(string.IsNullOrWhiteSpace))
{
errors.Add(new Error(AuditErrorCodes.OperationsMissing, "A list names one or more operations to list."));
}

if (Offset < 0)
{
errors.Add(new Error(AuditErrorCodes.OffsetInvalid, $"The offset is {Offset}; it is 0 or more."));
}

if (Limit is < 1 or > MaxLimit)
{
errors.Add(new Error(AuditErrorCodes.LimitInvalid, $"The limit is {Limit}; it is from 1 to {MaxLimit}."));
}

return errors.Count == 0 ? Result.Success() : Result.Failure(errors);
}
}
26 changes: 26 additions & 0 deletions src/FluentCMS.Core/Audit/SecurityEvents.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
namespace FluentCMS.Core.Audit;

// The security-relevant operations the desk lists in its own view (SEC-08): agent credentials, caps and refused moves, people's
// accounts, and routing rules. Each is appended by the lane that owns it; an operation no code appends yet simply matches nothing.
public static class SecurityEvents
{
public static IReadOnlyList<string> Operations { get; } =
[
"agent_credential.issued",
"agent_credential.revoked",
"agent.calls_per_period_exceeded",
"agent.change_sets_per_day_exceeded",
"agent.items_per_change_set_exceeded",
"agent.transition_refused",
"user.created",
"user.disabled",
"user.enabled",
"user.password_reset_issued",
"user.password_reset",
"user.login_linked",
"routing_rule.created",
"routing_rule.revised",
"routing_rule.activated",
"routing_rule.deactivated",
];
}
11 changes: 11 additions & 0 deletions src/FluentCMS.Core/Desk/DeskQueues.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,17 @@ public async Task<Result<IReadOnlyDictionary<DeskQueue, int>>> CountAsync(Cancel
return Result<IReadOnlyDictionary<DeskQueue, int>>.Success(counts);
}

public async Task<Result<int>> CountAsync(DeskQueue queue, CancellationToken cancellationToken = default)
{
if (queue == DeskQueue.StaleFacts)
{
var stale = await facts.StaleAsync(cancellationToken: cancellationToken);
return stale.IsSuccess ? Result<int>.Success(stale.Value.Count) : Result<int>.Failure(stale.Errors);
}

return await lists.CountAsync(Query(queue, clock.GetUtcNow()), cancellationToken);
}

public Task<Result<ChangeSetList>> ListChangeSetsAsync(DeskQueue queue, int offset, int limit, CancellationToken cancellationToken = default)
{
if (!ChangeSetQueues.Contains(queue))
Expand Down
3 changes: 3 additions & 0 deletions src/FluentCMS.Core/Desk/IDeskQueues.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ public interface IDeskQueues
// How many sets or facts each of the six queues holds now.
Task<Result<IReadOnlyDictionary<DeskQueue, int>>> CountAsync(CancellationToken cancellationToken = default);

// How many one queue holds, for a count shown on every page, such as awaiting me in the header (DSK-05).
Task<Result<int>> CountAsync(DeskQueue queue, CancellationToken cancellationToken = default);

// A page of a queue of change sets; stale facts is listed by ListStaleFactsAsync.
Task<Result<ChangeSetList>> ListChangeSetsAsync(DeskQueue queue, int offset, int limit, CancellationToken cancellationToken = default);

Expand Down
Loading
Loading