diff --git a/src/FluentCMS.Core/Agents/AgentPermissionErrorCodes.cs b/src/FluentCMS.Core/Agents/AgentPermissionErrorCodes.cs new file mode 100644 index 000000000..016cadab5 --- /dev/null +++ b/src/FluentCMS.Core/Agents/AgentPermissionErrorCodes.cs @@ -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"; +} diff --git a/src/FluentCMS.Core/Agents/AgentPermissions.cs b/src/FluentCMS.Core/Agents/AgentPermissions.cs new file mode 100644 index 000000000..251cdac67 --- /dev/null +++ b/src/FluentCMS.Core/Agents/AgentPermissions.cs @@ -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 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 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 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> AgentAsync(CancellationToken cancellationToken) + { + if (currentInitiator.Current is not AgentInitiator agent) + { + return Result.Success(null); + } + + var credential = await credentials.GetAsync(agent.CredentialId, cancellationToken); + if (!credential.IsSuccess) + { + return Result.Failure(credential.Errors); + } + + var active = credential.Value.CheckActive(clock.GetUtcNow()); + if (!active.IsSuccess) + { + return Result.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.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.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", + }; + } +} diff --git a/src/FluentCMS.Core/Agents/IAgentActivity.cs b/src/FluentCMS.Core/Agents/IAgentActivity.cs new file mode 100644 index 000000000..04152ed9f --- /dev/null +++ b/src/FluentCMS.Core/Agents/IAgentActivity.cs @@ -0,0 +1,7 @@ +namespace FluentCMS.Core.Agents; + +// What a credential has already done, counted for its caps (SEC-04). +public interface IAgentActivity +{ + Task ChangeSetsOpenedSinceAsync(Guid credentialId, DateTimeOffset since, CancellationToken cancellationToken = default); +} diff --git a/src/FluentCMS.Core/Agents/IAgentPermissions.cs b/src/FluentCMS.Core/Agents/IAgentPermissions.cs new file mode 100644 index 000000000..2bc83396b --- /dev/null +++ b/src/FluentCMS.Core/Agents/IAgentPermissions.cs @@ -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 CheckTransitionAsync(ChangeSet changeSet, ChangeSetState target, CancellationToken cancellationToken = default); + + Task 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 CheckChangeSetSizeAsync(ChangeSet changeSet, CancellationToken cancellationToken = default); +} diff --git a/src/FluentCMS.Core/CoreServiceCollectionExtensions.cs b/src/FluentCMS.Core/CoreServiceCollectionExtensions.cs index 5adc43b93..5c2a807e7 100644 --- a/src/FluentCMS.Core/CoreServiceCollectionExtensions.cs +++ b/src/FluentCMS.Core/CoreServiceCollectionExtensions.cs @@ -1,3 +1,4 @@ +using FluentCMS.Core.Agents; using FluentCMS.Core.Audit; using FluentCMS.Core.Extensions; using FluentCMS.Core.Schema; @@ -22,6 +23,9 @@ public static IServiceCollection AddFluentCmsCore(this IServiceCollection servic // Reads through IContentTypeDirectory, which Infrastructure provides. services.AddScoped(); + // Reads through IAgentCredentialDirectory and IAgentActivity, which Infrastructure provides. + services.AddScoped(); + return services; } } diff --git a/src/FluentCMS.Infrastructure/Agents/AgentActivity.cs b/src/FluentCMS.Infrastructure/Agents/AgentActivity.cs new file mode 100644 index 000000000..763c35b66 --- /dev/null +++ b/src/FluentCMS.Infrastructure/Agents/AgentActivity.cs @@ -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 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); + } +} diff --git a/src/FluentCMS.Infrastructure/ChangeSets/ChangeSetDirectory.cs b/src/FluentCMS.Infrastructure/ChangeSets/ChangeSetDirectory.cs index 241c1b9a5..22db9863a 100644 --- a/src/FluentCMS.Infrastructure/ChangeSets/ChangeSetDirectory.cs +++ b/src/FluentCMS.Infrastructure/ChangeSets/ChangeSetDirectory.cs @@ -1,3 +1,4 @@ +using FluentCMS.Core.Agents; using FluentCMS.Core.Audit; using FluentCMS.Core.ChangeSets; using FluentCMS.Core.Identity; @@ -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). @@ -64,6 +69,12 @@ public async Task> OpenAsync(NewChangeSet changeSet, Cancellat return opened; } + var allowed = await permissions.CheckNewChangeSetAsync(cancellationToken); + if (!allowed.IsSuccess) + { + return Result.Failure(allowed.Errors); + } + await using var scope = await unitOfWork.BeginAsync(cancellationToken); await InsertAsync(opened.Value, cancellationToken); @@ -103,6 +114,7 @@ public async Task> AddToSessionAsync( } var set = opened.Value; + var added = set.AddOperation(operation); var previousVersion = added.IsSuccess ? await ResolveAsync(operation, cancellationToken) : Result.Failure(added.Errors); if (!previousVersion.IsSuccess) @@ -110,6 +122,12 @@ public async Task> AddToSessionAsync( return Result.Failure(previousVersion.Errors); } + var allowed = await permissions.CheckNewChangeSetAsync(cancellationToken); + if (!allowed.IsSuccess) + { + return Result.Failure(allowed.Errors); + } + await using var scope = await unitOfWork.BeginAsync(cancellationToken); await InsertAsync(set, cancellationToken); await InsertOperationAsync(set, previousVersion.Value, cancellationToken); @@ -126,12 +144,12 @@ public async Task> AddToSessionAsync( public Task> 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> 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> AddAsync( @@ -148,6 +166,7 @@ private async Task> AddAsync( return Result.Failure(added.Errors); } + var sourcesAdded = set.AddSources(sources); if (!sourcesAdded.IsSuccess) { @@ -160,6 +179,13 @@ private async Task> AddAsync( return Result.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.Failure(allowed.Errors); + } + await using var scope = await unitOfWork.BeginAsync(cancellationToken); if (!await UpdateAsync(stored.Revision, set, cancellationToken)) { @@ -171,7 +197,11 @@ private async Task> AddAsync( return await CommitAsync(scope, set, Conflict(set.Id), cancellationToken); } - private async Task> MoveAsync(Guid changeSetId, Func move, CancellationToken cancellationToken) + private async Task> MoveAsync( + Guid changeSetId, + ChangeSetState target, + Func move, + CancellationToken cancellationToken) { var stored = await ReadAsync(changeSetId, cancellationToken); if (stored is null) @@ -179,6 +209,12 @@ private async Task> MoveAsync(Guid changeSetId, Func.Failure(allowed.Errors); + } + var set = stored.ChangeSet; var moved = move(set); if (!moved.IsSuccess) @@ -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); } diff --git a/src/FluentCMS.Infrastructure/InfrastructureServiceCollectionExtensions.cs b/src/FluentCMS.Infrastructure/InfrastructureServiceCollectionExtensions.cs index 6ecf547b8..c0bb13412 100644 --- a/src/FluentCMS.Infrastructure/InfrastructureServiceCollectionExtensions.cs +++ b/src/FluentCMS.Infrastructure/InfrastructureServiceCollectionExtensions.cs @@ -78,6 +78,7 @@ private static void AddChangeSets(IServiceCollection services) private static void AddAgents(IServiceCollection services) { services.AddScoped(); + services.AddScoped(); services.AddSingleton(); } diff --git a/tests/FluentCMS.IntegrationTests/ChangeSets/AgentPermissionTests.cs b/tests/FluentCMS.IntegrationTests/ChangeSets/AgentPermissionTests.cs new file mode 100644 index 000000000..a525d2754 --- /dev/null +++ b/tests/FluentCMS.IntegrationTests/ChangeSets/AgentPermissionTests.cs @@ -0,0 +1,271 @@ +using FluentCMS.Core.Agents; +using FluentCMS.Core.Audit; +using FluentCMS.Core.ChangeSets; +using FluentCMS.Core.Identity; +using FluentCMS.Core.Persistence; +using FluentCMS.Core.Results; +using FluentCMS.Core.Sites; +using FluentCMS.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using static FluentCMS.IntegrationTests.ChangeSets.ChangeSetScenario; + +namespace FluentCMS.IntegrationTests.ChangeSets; + +public sealed class AgentPermissionTests +{ + // A path from open to each state a transition in the table starts from, each step made by whoever may make it. + private static readonly Dictionary PathFromOpen = new() + { + [ChangeSetState.Open] = [], + [ChangeSetState.ChecksRunning] = [ChangeSetState.ChecksRunning], + [ChangeSetState.ChecksFailed] = [ChangeSetState.ChecksRunning, ChangeSetState.ChecksFailed], + [ChangeSetState.AwaitingReview] = [ChangeSetState.ChecksRunning, ChangeSetState.AwaitingReview], + [ChangeSetState.Approved] = [ChangeSetState.ChecksRunning, ChangeSetState.Approved], + [ChangeSetState.Published] = [ChangeSetState.ChecksRunning, ChangeSetState.Approved, ChangeSetState.Published], + }; + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task An_agent_makes_no_move_but_submit_and_reopen_and_each_refusal_is_logged(string provider) + { + await using var scenario = await ChangeSetScenario.CreateAsync(provider); + var product = await scenario.ContentTypeAsync("product", revised: true); + + foreach (var (from, to) in ChangeSetTransitions.All.Where(transition => transition.To is not (ChangeSetState.ChecksRunning or ChangeSetState.Open))) + { + var changeSet = await scenario.OpenAsync(Update(product)); + foreach (var state in PathFromOpen[from]) + { + Assert.True((await MoveAsync(scenario, changeSet.Id, state)).IsSuccess); + } + + var before = await scenario.ChangeSetRowAsync(changeSet.Id); + var events = (await scenario.ChangeSetEventsAsync(changeSet.Id)).Count; + + var refused = await scenario.RunAsync(directory => to == ChangeSetState.Rejected + ? directory.RejectAsync(changeSet.Id, "The price has no source.") + : directory.TransitionAsync(changeSet.Id, to)); + + var error = Assert.Single(refused.Errors); + Assert.Equal(AgentPermissionErrorCodes.TransitionNotPermitted, error.Code); + Assert.Contains(Agent.DisplayName, error.Message); + Assert.Contains("An agent submits its change set", error.Message); + + var after = await scenario.ChangeSetRowAsync(changeSet.Id); + Assert.Equal(from, after.State); + Assert.Equal(before.Revision, after.Revision); + Assert.Null(after.RejectionReason); + + var logged = await scenario.ChangeSetEventsAsync(changeSet.Id); + Assert.Equal(events + 1, logged.Count); + var refusal = logged[^1]; + Assert.Equal("agent.transition_refused", refusal.Operation); + Assert.Equal("change_set", refusal.SubjectType); + Assert.Equal(changeSet.Id, refusal.SubjectId); + Assert.Equal(InitiatorKinds.Agent, refusal.InitiatorKind); + Assert.Equal(Agent.CredentialId, refusal.AgentCredentialId); + } + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task An_agent_submits_its_set_and_reopens_it_after_failed_checks(string provider) + { + await using var scenario = await ChangeSetScenario.CreateAsync(provider); + var product = await scenario.ContentTypeAsync("product", revised: true); + var changeSet = await scenario.OpenAsync(Update(product)); + + var submitted = await scenario.RunAsync(directory => directory.TransitionAsync(changeSet.Id, ChangeSetState.ChecksRunning)); + await MoveAsync(scenario, changeSet.Id, ChangeSetState.ChecksFailed); + var reopened = await scenario.RunAsync(directory => directory.TransitionAsync(changeSet.Id, ChangeSetState.Open)); + + Assert.True(submitted.IsSuccess); + Assert.True(reopened.IsSuccess); + Assert.Equal(ChangeSetState.Open, (await scenario.ChangeSetRowAsync(changeSet.Id)).State); + + var events = await scenario.ChangeSetEventsAsync(changeSet.Id); + Assert.DoesNotContain(events, row => row.Operation.StartsWith("agent.", StringComparison.Ordinal)); + Assert.All( + events.Where(row => row.Operation is "change_set.submitted" or "change_set.reopened"), + row => Assert.Equal(Agent.CredentialId, row.AgentCredentialId)); + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task An_agent_submits_reopens_and_adds_to_its_own_change_sets_only(string provider) + { + await using var scenario = await ChangeSetScenario.CreateAsync(provider); + var category = await scenario.ContentTypeAsync("category"); + var product = await scenario.ContentTypeAsync("product", revised: true); + var persons = (await scenario.AsPersonAsync(directory => directory.OpenAsync(CatalogSync))).Value; + persons = (await scenario.AsPersonAsync(directory => directory.AddOperationAsync(persons.Id, Update(product)))).Value; + var before = await scenario.ChangeSetRowAsync(persons.Id); + + var submitted = await scenario.RunAsync(directory => directory.TransitionAsync(persons.Id, ChangeSetState.ChecksRunning)); + var added = await scenario.RunAsync(directory => directory.AddOperationAsync(persons.Id, Create(category))); + + foreach (var refused in new[] { submitted, added }) + { + var error = Assert.Single(refused.Errors); + Assert.Equal(AgentPermissionErrorCodes.NotOwner, error.Code); + Assert.Contains(Amir.DisplayName, error.Message); + } + + var after = await scenario.ChangeSetRowAsync(persons.Id); + Assert.Equal((ChangeSetState.Open, before.Revision), (after.State, after.Revision)); + Assert.Single(await scenario.OperationRowsAsync(persons.Id)); + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task An_operation_past_the_agents_items_cap_is_refused_naming_the_cap(string provider) + { + await using var scenario = await ChangeSetScenario.CreateAsync(provider); + var category = await scenario.ContentTypeAsync("category"); + var product = await scenario.ContentTypeAsync("product", revised: true); + var region = await scenario.ContentTypeAsync("region"); + await scenario.UpdateAgentAsync(row => row.ItemsPerChangeSet = 2); + + var changeSet = await scenario.OpenAsync(Create(category), Update(product)); + var refused = await scenario.RunAsync(directory => directory.AddOperationAsync(changeSet.Id, Create(region))); + + var error = Assert.Single(refused.Errors); + Assert.Equal(AgentPermissionErrorCodes.CapExceeded, error.Code); + Assert.Contains("items per change set", error.Message); + Assert.Contains("allows 2", error.Message); + Assert.Equal(2, (await scenario.OperationRowsAsync(changeSet.Id)).Count); + + var refusal = (await scenario.ChangeSetEventsAsync(changeSet.Id))[^1]; + Assert.Equal("agent.items_per_change_set_exceeded", refusal.Operation); + Assert.Equal(changeSet.Id, refusal.SubjectId); + + // Joining a session's set meets the same cap, and a person is held to none of it. + await scenario.RunAsync(directory => directory.AddToSessionAsync("example-session", CatalogSync, Create(category))); + await scenario.RunAsync(directory => directory.AddToSessionAsync("example-session", CatalogSync, Update(product))); + var third = await scenario.RunAsync(directory => directory.AddToSessionAsync("example-session", CatalogSync, Create(region))); + var person = await scenario.AsPersonAsync(directory => directory.AddOperationAsync(changeSet.Id, Create(region))); + + Assert.Equal(AgentPermissionErrorCodes.CapExceeded, Assert.Single(third.Errors).Code); + Assert.True(person.IsSuccess); + Assert.Equal(3, (await scenario.OperationRowsAsync(changeSet.Id)).Count); + } + + // The second writer reads while the first has not committed, so the cap passes for both and the revision has the last word. + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task Of_two_concurrent_operations_that_would_each_reach_the_cap_one_is_stored(string provider) + { + await using var scenario = await ChangeSetScenario.CreateAsync(provider); + var category = await scenario.ContentTypeAsync("category"); + var product = await scenario.ContentTypeAsync("product", revised: true); + var region = await scenario.ContentTypeAsync("region"); + await scenario.UpdateAgentAsync(row => row.ItemsPerChangeSet = 2); + var changeSet = await scenario.OpenAsync(Create(category)); + + Result lost; + await using (var scope = scenario.Services.CreateAsyncScope()) + { + var work = await scope.ServiceProvider.GetRequiredService().BeginAsync(); + Assert.True((await scope.ServiceProvider.GetRequiredService().AddOperationAsync(changeSet.Id, Update(product))).IsSuccess); + + var second = Task.Run(() => scenario.RunAsync(directory => directory.AddOperationAsync(changeSet.Id, Create(region)))); + await Task.Delay(TimeSpan.FromMilliseconds(500)); + await work.CommitAsync(); + await work.DisposeAsync(); + lost = await second; + } + + Assert.False(lost.IsSuccess); + Assert.Equal(2, (await scenario.OperationRowsAsync(changeSet.Id)).Count); + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task A_change_set_past_the_agents_daily_cap_is_refused_until_the_day_has_passed(string provider) + { + await using var scenario = await ChangeSetScenario.CreateAsync(provider); + var category = await scenario.ContentTypeAsync("category"); + var product = await scenario.ContentTypeAsync("product", revised: true); + await scenario.UpdateAgentAsync(row => row.ChangeSetsPerDay = 1); + + var first = await scenario.RunAsync(directory => directory.AddToSessionAsync("example-session", CatalogSync, Create(category))); + var joined = await scenario.RunAsync(directory => directory.AddToSessionAsync("example-session", CatalogSync, Update(product))); + var opened = await scenario.RunAsync(directory => directory.OpenAsync(CatalogSync)); + var otherSession = await scenario.RunAsync(directory => directory.AddToSessionAsync("another-session", CatalogSync, Create(category))); + var person = await scenario.AsPersonAsync(directory => directory.OpenAsync(CatalogSync)); + + Assert.True(first.IsSuccess); + Assert.True(joined.IsSuccess); + Assert.True(person.IsSuccess); + foreach (var refused in new[] { opened, otherSession }) + { + var error = Assert.Single(refused.Errors); + Assert.Equal(AgentPermissionErrorCodes.CapExceeded, error.Code); + Assert.Contains("change sets per day", error.Message); + Assert.Contains("its cap is 1", error.Message); + } + + Assert.Equal(2, (await scenario.ChangeSetRowsAsync()).Count); + var refusals = await scenario.QueryAsync(context => context.AuditEvents + .AsNoTracking() + .Where(row => row.Operation == "agent.change_sets_per_day_exceeded") + .ToListAsync()); + Assert.Equal(2, refusals.Count); + Assert.All(refusals, row => Assert.Equal(("agent_credential", Agent.CredentialId), (row.SubjectType, row.SubjectId!.Value))); + + scenario.AdvanceClock(TimeSpan.FromDays(1)); + + Assert.True((await scenario.RunAsync(directory => directory.OpenAsync(CatalogSync))).IsSuccess); + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task An_agent_without_an_active_credential_for_the_site_writes_nothing(string provider) + { + await using var scenario = await ChangeSetScenario.CreateAsync(provider); + var category = await scenario.ContentTypeAsync("category"); + var product = await scenario.ContentTypeAsync("product", revised: true); + var changeSet = await scenario.OpenAsync(Update(product)); + var events = (await scenario.ChangeSetEventsAsync()).Count; + + await scenario.UpdateAgentAsync(row => row.Sites = [Guid.NewGuid()]); + var outsideSite = await scenario.RunAsync(directory => directory.OpenAsync(CatalogSync)); + + await scenario.UpdateAgentAsync(row => row.Sites = [Site.Default.Id]); + await scenario.UpdateAgentAsync(row => row.ExpiresAt = new DateTimeOffset(2026, 10, 18, 0, 0, 0, TimeSpan.Zero)); + var expired = await scenario.RunAsync(directory => directory.TransitionAsync(changeSet.Id, ChangeSetState.ChecksRunning)); + + await scenario.UpdateAgentAsync(row => row.ExpiresAt = new DateTimeOffset(2027, 10, 1, 0, 0, 0, TimeSpan.Zero)); + await scenario.UpdateAgentAsync(row => row.RevokedAt = new DateTimeOffset(2026, 10, 18, 0, 0, 0, TimeSpan.Zero)); + var revoked = await scenario.RunAsync(directory => directory.AddOperationAsync(changeSet.Id, Create(category))); + + scenario.Initiator = new AgentInitiator(Guid.NewGuid(), "unknown-agent"); + var unknown = await scenario.RunAsync(directory => directory.OpenAsync(CatalogSync)); + + Assert.Equal(AgentPermissionErrorCodes.SiteOutOfScope, Assert.Single(outsideSite.Errors).Code); + Assert.Equal(AgentCredentialErrorCodes.Expired, Assert.Single(expired.Errors).Code); + Assert.Equal(AgentCredentialErrorCodes.Revoked, Assert.Single(revoked.Errors).Code); + Assert.Equal(AgentCredentialErrorCodes.CredentialNotFound, Assert.Single(unknown.Errors).Code); + + Assert.Single(await scenario.ChangeSetRowsAsync()); + Assert.Single(await scenario.OperationRowsAsync(changeSet.Id)); + Assert.Equal(ChangeSetState.Open, (await scenario.ChangeSetRowAsync(changeSet.Id)).State); + Assert.Equal(events, (await scenario.ChangeSetEventsAsync()).Count); + } + + // Submitting and reopening are the agent's own moves; the others are made as a person, as the checks and the desk will. + private static Task> MoveAsync(ChangeSetScenario scenario, Guid id, ChangeSetState state) + { + return state is ChangeSetState.ChecksRunning or ChangeSetState.Open + ? scenario.RunAsync(directory => directory.TransitionAsync(id, state)) + : scenario.AsPersonAsync(directory => directory.TransitionAsync(id, state)); + } +} diff --git a/tests/FluentCMS.IntegrationTests/ChangeSets/ChangeSetDirectoryTests.cs b/tests/FluentCMS.IntegrationTests/ChangeSets/ChangeSetDirectoryTests.cs index 9e8c1de55..d9e0b5a8d 100644 --- a/tests/FluentCMS.IntegrationTests/ChangeSets/ChangeSetDirectoryTests.cs +++ b/tests/FluentCMS.IntegrationTests/ChangeSets/ChangeSetDirectoryTests.cs @@ -184,9 +184,9 @@ public async Task A_refused_transition_writes_nothing(string provider) var events = (await scenario.ChangeSetEventsAsync()).Count; var withoutOperations = await scenario.RunAsync(directory => directory.TransitionAsync(empty.Id, ChangeSetState.ChecksRunning)); - var skipped = await scenario.RunAsync(directory => directory.TransitionAsync(reviewed.Id, ChangeSetState.Published)); - var silentRejection = await scenario.RunAsync(directory => directory.TransitionAsync(reviewed.Id, ChangeSetState.Rejected)); - var blankReason = await scenario.RunAsync(directory => directory.RejectAsync(reviewed.Id, " ")); + var skipped = await scenario.AsPersonAsync(directory => directory.TransitionAsync(reviewed.Id, ChangeSetState.Published)); + var silentRejection = await scenario.AsPersonAsync(directory => directory.TransitionAsync(reviewed.Id, ChangeSetState.Rejected)); + var blankReason = await scenario.AsPersonAsync(directory => directory.RejectAsync(reviewed.Id, " ")); var unknown = await scenario.RunAsync(directory => directory.TransitionAsync(Guid.NewGuid(), ChangeSetState.ChecksRunning)); Assert.Equal(ChangeSetErrorCodes.OperationsMissing, Assert.Single(withoutOperations.Errors).Code); @@ -236,7 +236,7 @@ await Assert.ThrowsAsync( Assert.Equal(ChangeSetState.ChecksRunning, after.State); Assert.Equal(before.Revision, after.Revision); Assert.Equal(events, (await scenario.ChangeSetEventsAsync()).Count); - Assert.True((await scenario.RunAsync(directory => directory.TransitionAsync(changeSet.Id, ChangeSetState.AwaitingReview))).IsSuccess); + Assert.True((await scenario.AsPersonAsync(directory => directory.TransitionAsync(changeSet.Id, ChangeSetState.AwaitingReview))).IsSuccess); } [Theory] @@ -324,6 +324,7 @@ public async Task A_failed_operation_leaves_the_callers_unit_of_work_able_to_com id = (await directory.OpenAsync(CatalogSync)).Value.Id; var unknown = await directory.AddOperationAsync(id, ChangeOperation.Create(OperationKind.Update, ItemKind.Schema, Guid.NewGuid(), 1, 2).Value); Assert.True((await directory.AddOperationAsync(id, Update(product))).IsSuccess); + scenario.Initiator = Amir; var refused = await directory.TransitionAsync(id, ChangeSetState.Published); Assert.Equal(ChangeSetErrorCodes.ItemNotFound, Assert.Single(unknown.Errors).Code); @@ -345,6 +346,7 @@ public async Task Of_two_concurrent_transitions_one_wins_and_the_other_gets_a_co var product = await scenario.ContentTypeAsync("product", revised: true); var changeSet = await scenario.OpenAsync(Update(product)); await MoveAsync(scenario, changeSet.Id, ChangeSetState.ChecksRunning); + scenario.Initiator = Amir; var lost = await RaceAsync( scenario, @@ -375,11 +377,14 @@ public async Task Of_two_concurrent_first_operations_in_a_session_one_opens_the_ Assert.Equal(["change_set.opened", "change_set.operation_added"], (await scenario.ChangeSetEventsAsync()).Select(row => row.Operation)); } + // Submitting and reopening are the caller's own moves; the others are made as a person, as the checks and the desk will. private static Task> MoveAsync(ChangeSetScenario scenario, Guid id, ChangeSetState state) { - return scenario.RunAsync(directory => state == ChangeSetState.Rejected + Func>> move = directory => state == ChangeSetState.Rejected ? directory.RejectAsync(id, "The price has no source.") - : directory.TransitionAsync(id, state)); + : directory.TransitionAsync(id, state); + + return state is ChangeSetState.ChecksRunning or ChangeSetState.Open ? scenario.RunAsync(move) : scenario.AsPersonAsync(move); } private static async Task> RaceAsync( diff --git a/tests/FluentCMS.IntegrationTests/ChangeSets/ChangeSetScenario.cs b/tests/FluentCMS.IntegrationTests/ChangeSets/ChangeSetScenario.cs index 79a3c7efa..ef4c1dc93 100644 --- a/tests/FluentCMS.IntegrationTests/ChangeSets/ChangeSetScenario.cs +++ b/tests/FluentCMS.IntegrationTests/ChangeSets/ChangeSetScenario.cs @@ -1,6 +1,10 @@ +using System.Security.Cryptography; +using FluentCMS.Core.Agents; using FluentCMS.Core.ChangeSets; using FluentCMS.Core.Identity; using FluentCMS.Core.Schema; +using FluentCMS.Core.Sites; +using FluentCMS.Infrastructure.Agents; using FluentCMS.Infrastructure.Audit; using FluentCMS.Infrastructure.ChangeSets; using FluentCMS.Infrastructure.Persistence; @@ -20,12 +24,14 @@ internal sealed class ChangeSetScenario : IAsyncDisposable private readonly TemporaryDatabase _database; private readonly SwitchableInitiator _initiator; + private readonly TickingClock _clock; - private ChangeSetScenario(TemporaryDatabase database, ServiceProvider services, SwitchableInitiator initiator) + private ChangeSetScenario(TemporaryDatabase database, ServiceProvider services, SwitchableInitiator initiator, TickingClock clock) { _database = database; Services = services; _initiator = initiator; + _clock = clock; } public ServiceProvider Services { get; } @@ -40,14 +46,52 @@ public static async Task CreateAsync(string provider) { var database = await TemporaryDatabase.CreateAsync(provider); var initiator = new SwitchableInitiator { Current = Agent }; + var clock = new TickingClock(); var services = TestServices.Build( database.Configuration, collection => collection .AddSingleton(initiator) - .AddSingleton(new TickingClock())); + .AddSingleton(clock)); await TestServices.MigrateAsync(services); - return new ChangeSetScenario(database, services, initiator); + var scenario = new ChangeSetScenario(database, services, initiator, clock); + await scenario.QueryAsync(async context => + { + context.AgentCredentials.Add(AgentCredential()); + return await context.SaveChangesAsync(); + }); + + return scenario; + } + + // As a person, who stands in for the checks, routing and the desk, whose moves an agent cannot make (MCP-06). + public async Task AsPersonAsync(Func> action) + { + var initiator = Initiator; + Initiator = Amir; + try + { + return await RunAsync(action); + } + finally + { + Initiator = initiator; + } + } + + public void AdvanceClock(TimeSpan by) + { + _clock.Advance(by); + } + + // Changes Agent's stored credential, such as a cap, its expiry or its revocation. + public Task UpdateAgentAsync(Action change) + { + return QueryAsync(async context => + { + change(await context.AgentCredentials.SingleAsync(row => row.Id == Agent.CredentialId)); + return await context.SaveChangesAsync(); + }); } // Each call gets its own scope and DbContext, as separate requests would. @@ -134,6 +178,26 @@ public static ChangeOperation Create(ContentType type) return ChangeOperation.Create(OperationKind.Create, ItemKind.Schema, type.Id, null, 1).Value; } + // The stored credential behind Agent, scoped to the site with room to spare, so only a test that lowers a cap meets one. + private static AgentCredentialRow AgentCredential() + { + return new AgentCredentialRow + { + Id = Agent.CredentialId, + DisplayName = Agent.DisplayName, + KeyHash = RandomNumberGenerator.GetBytes(32), + IssuedAt = new DateTimeOffset(2026, 10, 1, 0, 0, 0, TimeSpan.Zero), + ExpiresAt = new DateTimeOffset(2027, 10, 1, 0, 0, 0, TimeSpan.Zero), + Sites = [Site.Default.Id], + Operations = [AgentOperation.Read, AgentOperation.Create, AgentOperation.Update, AgentOperation.Delete], + CallsPerPeriod = 1000, + CallPeriod = TimeSpan.FromHours(1), + LlmSpendPeriod = TimeSpan.FromDays(1), + ItemsPerChangeSet = 1000, + ChangeSetsPerDay = 1000, + }; + } + private sealed class SwitchableInitiator : ICurrentInitiator { public Initiator? Current { get; set; } @@ -148,5 +212,10 @@ public override DateTimeOffset GetUtcNow() { return new DateTimeOffset(Interlocked.Add(ref _ticks, TimeSpan.TicksPerMillisecond), TimeSpan.Zero); } + + public void Advance(TimeSpan by) + { + Interlocked.Add(ref _ticks, by.Ticks); + } } }