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
9 changes: 9 additions & 0 deletions src/FluentCMS.Core/Agents/AgentPermissionErrorCodes.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
namespace FluentCMS.Core.Agents;

public static class AgentPermissionErrorCodes
{
public const string TransitionNotPermitted = "agent_transition_not_permitted";
public const string CapExceeded = "agent_cap_exceeded";
public const string SiteOutOfScope = "agent_site_out_of_scope";
public const string NotOwner = "agent_not_change_set_owner";
}
194 changes: 194 additions & 0 deletions src/FluentCMS.Core/Agents/AgentPermissions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
using FluentCMS.Core.Audit;
using FluentCMS.Core.ChangeSets;
using FluentCMS.Core.Identity;
using FluentCMS.Core.Persistence;
using FluentCMS.Core.Results;
using FluentCMS.Core.Sites;

namespace FluentCMS.Core.Agents;

internal sealed class AgentPermissions(
ICurrentInitiator currentInitiator,
IAgentCredentialDirectory credentials,
IAgentActivity activity,
IUnitOfWork unitOfWork,
IAuditLog auditLog,
TimeProvider clock) : IAgentPermissions
{
// Moves the checks, routing and people make. Listing what an agent may do, rather than what it may not, keeps a state
// added to the table out of an agent's reach until someone decides otherwise.
private static readonly ChangeSetState[] AgentTargets = [ChangeSetState.ChecksRunning, ChangeSetState.Open];

private static readonly TimeSpan Day = TimeSpan.FromDays(1);

public async Task<Result> CheckTransitionAsync(ChangeSet changeSet, ChangeSetState target, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(changeSet);

var agent = await AgentAsync(cancellationToken);
if (!agent.IsSuccess)
{
return Result.Failure(agent.Errors);
}

if (agent.Value is not { } credential)
{
return Result.Success();
}

var changeSetId = changeSet.Id;
if (AgentTargets.Contains(target))
{
return Owns(credential, changeSet);
}

await RecordAsync(
new NewAuditEvent("agent.transition_refused") { SubjectType = "change_set", SubjectId = changeSetId, ChangeSetId = changeSetId },
cancellationToken);

return Result.Failure(
AgentPermissionErrorCodes.TransitionNotPermitted,
$"The credential '{credential.DisplayName}' cannot move change set {changeSetId} to {Describe(target)}: {Owner(target)}. "
+ "An agent submits its change set for checks and reopens it after failed checks.");
}

public async Task<Result> CheckNewChangeSetAsync(CancellationToken cancellationToken = default)
{
var agent = await AgentAsync(cancellationToken);
if (!agent.IsSuccess)
{
return Result.Failure(agent.Errors);
}

if (agent.Value is not { } credential)
{
return Result.Success();
}

var cap = credential.Caps.ChangeSetsPerDay;
var opened = await activity.ChangeSetsOpenedSinceAsync(credential.Id, clock.GetUtcNow() - Day, cancellationToken);
if (opened < cap)
{
return Result.Success();
}

await RecordAsync(
new NewAuditEvent("agent.change_sets_per_day_exceeded") { SubjectType = "agent_credential", SubjectId = credential.Id },
cancellationToken);

return Result.Failure(
AgentPermissionErrorCodes.CapExceeded,
$"The credential '{credential.DisplayName}' has opened {opened} change sets in the last 24 hours, and its cap is {cap} "
+ $"(change sets per day). Add the operation to a change set that is still open, or wait until fewer than {cap} were opened in the last 24 hours.");
}

public async Task<Result> CheckChangeSetSizeAsync(ChangeSet changeSet, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(changeSet);

var agent = await AgentAsync(cancellationToken);
if (!agent.IsSuccess)
{
return Result.Failure(agent.Errors);
}

if (agent.Value is not { } credential)
{
return Result.Success();
}

var owned = Owns(credential, changeSet);
if (!owned.IsSuccess)
{
return owned;
}

var cap = credential.Caps.ItemsPerChangeSet;
if (changeSet.Operations.Count <= cap)
{
return Result.Success();
}

await RecordAsync(
new NewAuditEvent("agent.items_per_change_set_exceeded") { SubjectType = "change_set", SubjectId = changeSet.Id, ChangeSetId = changeSet.Id },
cancellationToken);

return Result.Failure(
AgentPermissionErrorCodes.CapExceeded,
$"Change set {changeSet.Id} already holds {changeSet.Operations.Count - 1} items, and the credential '{credential.DisplayName}' allows "
+ $"{cap} (items per change set). Submit this change set and add the rest to a new one.");
}

// An agent works on the change sets it opened; a person's set, or another agent's, is not its to submit or add to.
private static Result Owns(AgentCredential credential, ChangeSet changeSet)
{
return changeSet.Initiator is AgentInitiator owner && owner.CredentialId == credential.Id
? Result.Success()
: Result.Failure(
AgentPermissionErrorCodes.NotOwner,
$"Change set {changeSet.Id} was opened by {changeSet.Initiator.DisplayName}, not by the credential '{credential.DisplayName}'; "
+ "an agent submits, reopens and adds to its own change sets only. Open a change set of your own.");
}

// Null for anyone but an agent; the agent's credential while it is active and its scope includes the site.
private async Task<Result<AgentCredential?>> AgentAsync(CancellationToken cancellationToken)
{
if (currentInitiator.Current is not AgentInitiator agent)
{
return Result<AgentCredential?>.Success(null);
}

var credential = await credentials.GetAsync(agent.CredentialId, cancellationToken);
if (!credential.IsSuccess)
{
return Result<AgentCredential?>.Failure(credential.Errors);
}

var active = credential.Value.CheckActive(clock.GetUtcNow());
if (!active.IsSuccess)
{
return Result<AgentCredential?>.Failure(active.Errors);
}

// Until multi-site (MST-01), everything belongs to the one site (SCH-07).
if (!credential.Value.Scope.AllowsSite(Site.Default.Id))
{
return Result<AgentCredential?>.Failure(
AgentPermissionErrorCodes.SiteOutOfScope,
$"The credential '{credential.Value.DisplayName}' does not include the site '{Site.Default.Name}' in its scope; "
+ "ask an administrator for a credential that does.");
}

return Result<AgentCredential?>.Success(credential.Value);
}

// The refused write leaves nothing, so the event commits on its own, or with the caller's unit of work when there is one.
private async Task RecordAsync(NewAuditEvent refusal, CancellationToken cancellationToken)
{
await using var scope = await unitOfWork.BeginAsync(cancellationToken);
await auditLog.AppendAsync(refusal, cancellationToken);
await scope.CommitAsync(cancellationToken);
}

private static string Describe(ChangeSetState state)
{
return state switch
{
ChangeSetState.ChecksFailed => "checks failed",
ChangeSetState.AwaitingReview => "awaiting review",
_ => state.ToString().ToLowerInvariant(),
};
}

private static string Owner(ChangeSetState target)
{
return target switch
{
ChangeSetState.ChecksFailed or ChangeSetState.AwaitingReview => "the checks decide that once they have run",
ChangeSetState.Approved or ChangeSetState.Rejected => "a person decides that when reviewing the change set",
ChangeSetState.Published => "publishing follows approval and is never done by an agent",
ChangeSetState.Reverted => "a person reverts a published change set",
_ => "that move is not one an agent makes",
};
}
}
7 changes: 7 additions & 0 deletions src/FluentCMS.Core/Agents/IAgentActivity.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace FluentCMS.Core.Agents;

// What a credential has already done, counted for its caps (SEC-04).
public interface IAgentActivity
{
Task<int> ChangeSetsOpenedSinceAsync(Guid credentialId, DateTimeOffset since, CancellationToken cancellationToken = default);
}
19 changes: 19 additions & 0 deletions src/FluentCMS.Core/Agents/IAgentPermissions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
using FluentCMS.Core.ChangeSets;
using FluentCMS.Core.Results;

namespace FluentCMS.Core.Agents;

// What the current caller may do (MCP-06, SEC-02, SEC-04), decided in Core so tools and forms cannot diverge (ADR-0007).
// People pass every check. An agent is held to its stored credential, read again for the call, so a credential revoked or
// expired since its key was accepted, or one whose scope lacks the site, is refused. A refused cap or move is recorded as a
// security event (SEC-08); the refused write itself is left to the caller, which writes nothing.
public interface IAgentPermissions
{
// An agent submits its own set and reopens it after failed checks; every other move is the checks', routing's or a person's.
Task<Result> CheckTransitionAsync(ChangeSet changeSet, ChangeSetState target, CancellationToken cancellationToken = default);

Task<Result> CheckNewChangeSetAsync(CancellationToken cancellationToken = default);

// Called with the agent's own set as read for the write and the new operation added in memory, before anything is written.
Task<Result> CheckChangeSetSizeAsync(ChangeSet changeSet, CancellationToken cancellationToken = default);
}
4 changes: 4 additions & 0 deletions src/FluentCMS.Core/CoreServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using FluentCMS.Core.Agents;
using FluentCMS.Core.Audit;
using FluentCMS.Core.Extensions;
using FluentCMS.Core.Schema;
Expand All @@ -22,6 +23,9 @@ public static IServiceCollection AddFluentCmsCore(this IServiceCollection servic
// Reads through IContentTypeDirectory, which Infrastructure provides.
services.AddScoped<ISchemaDocuments, SchemaDocuments>();

// Reads through IAgentCredentialDirectory and IAgentActivity, which Infrastructure provides.
services.AddScoped<IAgentPermissions, AgentPermissions>();

return services;
}
}
22 changes: 22 additions & 0 deletions src/FluentCMS.Infrastructure/Agents/AgentActivity.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
using FluentCMS.Core.Agents;
using FluentCMS.Infrastructure.ChangeSets;
using FluentCMS.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;

namespace FluentCMS.Infrastructure.Agents;

// Counted from the audit log, which records every change set an agent opens with its credential (AUD-01).
internal sealed class AgentActivity(FluentCmsDbContext context) : IAgentActivity
{
// SQL rather than LINQ because EF Core will not compare a DateTimeOffset on SQLite, which stores it as text; stored
// timestamps are all UTC, so the text compares in time order on both providers, as in AuditReader.
public Task<int> ChangeSetsOpenedSinceAsync(Guid credentialId, DateTimeOffset since, CancellationToken cancellationToken = default)
{
return context.AuditEvents
.FromSql($"""
SELECT * FROM "AuditEvents"
WHERE "Operation" = {ChangeSetDirectory.OpenedOperation} AND "AgentCredentialId" = {credentialId} AND "Timestamp" >= {since.ToUniversalTime()}
""")
.CountAsync(cancellationToken);
}
}
46 changes: 41 additions & 5 deletions src/FluentCMS.Infrastructure/ChangeSets/ChangeSetDirectory.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using FluentCMS.Core.Agents;
using FluentCMS.Core.Audit;
using FluentCMS.Core.ChangeSets;
using FluentCMS.Core.Identity;
Expand All @@ -16,8 +17,12 @@ internal sealed class ChangeSetDirectory(
FluentCmsDbContext context,
IUnitOfWork unitOfWork,
IAuditLog auditLog,
ICurrentInitiator currentInitiator) : IChangeSetDirectory
ICurrentInitiator currentInitiator,
IAgentPermissions permissions) : IChangeSetDirectory
{
// AgentActivity counts these events for the change-sets-per-day cap.
internal const string OpenedOperation = "change_set.opened";

private const string SubjectType = "change_set";

// Until multi-site (MST-01), every set belongs to the one site (SCH-07).
Expand Down Expand Up @@ -64,6 +69,12 @@ public async Task<Result<ChangeSet>> OpenAsync(NewChangeSet changeSet, Cancellat
return opened;
}

var allowed = await permissions.CheckNewChangeSetAsync(cancellationToken);
if (!allowed.IsSuccess)
{
return Result<ChangeSet>.Failure(allowed.Errors);
}

await using var scope = await unitOfWork.BeginAsync(cancellationToken);
await InsertAsync(opened.Value, cancellationToken);

Expand Down Expand Up @@ -103,13 +114,20 @@ public async Task<Result<ChangeSet>> AddToSessionAsync(
}

var set = opened.Value;

var added = set.AddOperation(operation);
var previousVersion = added.IsSuccess ? await ResolveAsync(operation, cancellationToken) : Result<Guid?>.Failure(added.Errors);
if (!previousVersion.IsSuccess)
{
return Result<ChangeSet>.Failure(previousVersion.Errors);
}

var allowed = await permissions.CheckNewChangeSetAsync(cancellationToken);
if (!allowed.IsSuccess)
{
return Result<ChangeSet>.Failure(allowed.Errors);
}

await using var scope = await unitOfWork.BeginAsync(cancellationToken);
await InsertAsync(set, cancellationToken);
await InsertOperationAsync(set, previousVersion.Value, cancellationToken);
Expand All @@ -126,12 +144,12 @@ public async Task<Result<ChangeSet>> AddToSessionAsync(

public Task<Result<ChangeSet>> TransitionAsync(Guid changeSetId, ChangeSetState target, CancellationToken cancellationToken = default)
{
return MoveAsync(changeSetId, set => set.TransitionTo(target), cancellationToken);
return MoveAsync(changeSetId, target, set => set.TransitionTo(target), cancellationToken);
}

public Task<Result<ChangeSet>> RejectAsync(Guid changeSetId, string reason, CancellationToken cancellationToken = default)
{
return MoveAsync(changeSetId, set => set.Reject(reason), cancellationToken);
return MoveAsync(changeSetId, ChangeSetState.Rejected, set => set.Reject(reason), cancellationToken);
}

private async Task<Result<ChangeSet>> AddAsync(
Expand All @@ -148,6 +166,7 @@ private async Task<Result<ChangeSet>> AddAsync(
return Result<ChangeSet>.Failure(added.Errors);
}


var sourcesAdded = set.AddSources(sources);
if (!sourcesAdded.IsSuccess)
{
Expand All @@ -160,6 +179,13 @@ private async Task<Result<ChangeSet>> AddAsync(
return Result<ChangeSet>.Failure(previousVersion.Errors);
}

// Checked against the read whose revision guards the write, so two concurrent operations cannot both pass the cap.
var allowed = await permissions.CheckChangeSetSizeAsync(set, cancellationToken);
if (!allowed.IsSuccess)
{
return Result<ChangeSet>.Failure(allowed.Errors);
}

await using var scope = await unitOfWork.BeginAsync(cancellationToken);
if (!await UpdateAsync(stored.Revision, set, cancellationToken))
{
Expand All @@ -171,14 +197,24 @@ private async Task<Result<ChangeSet>> AddAsync(
return await CommitAsync(scope, set, Conflict(set.Id), cancellationToken);
}

private async Task<Result<ChangeSet>> MoveAsync(Guid changeSetId, Func<ChangeSet, Result> move, CancellationToken cancellationToken)
private async Task<Result<ChangeSet>> MoveAsync(
Guid changeSetId,
ChangeSetState target,
Func<ChangeSet, Result> move,
CancellationToken cancellationToken)
{
var stored = await ReadAsync(changeSetId, cancellationToken);
if (stored is null)
{
return NotFound(changeSetId);
}

var allowed = await permissions.CheckTransitionAsync(stored.ChangeSet, target, cancellationToken);
if (!allowed.IsSuccess)
{
return Result<ChangeSet>.Failure(allowed.Errors);
}

var set = stored.ChangeSet;
var moved = move(set);
if (!moved.IsSuccess)
Expand Down Expand Up @@ -227,7 +263,7 @@ private async Task InsertAsync(ChangeSet set, CancellationToken cancellationToke
});

await auditLog.AppendAsync(
new NewAuditEvent("change_set.opened") { SubjectType = SubjectType, SubjectId = set.Id, ChangeSetId = set.Id, Sources = set.Sources },
new NewAuditEvent(OpenedOperation) { SubjectType = SubjectType, SubjectId = set.Id, ChangeSetId = set.Id, Sources = set.Sources },
cancellationToken);
}

Expand Down
Loading
Loading