From 0ac234d8cfed8de822a03d7e2f9513b13675c5be Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Mon, 19 Oct 2026 03:07:22 -0400 Subject: [PATCH 1/6] WIP: harden the system initiator --- .../CoreServiceCollectionExtensions.cs | 5 +++ src/FluentCMS.Core/Identity/Initiator.cs | 3 +- src/FluentCMS.Core/Jobs/EngineJobs.cs | 35 +++++++++++++++++++ src/FluentCMS.Core/Jobs/JobErrorCodes.cs | 1 + .../Identity/IdentityPasswordReset.cs | 9 +++++ .../Identity/SystemInitiatorTests.cs | 5 +-- 6 files changed, 55 insertions(+), 3 deletions(-) create mode 100644 src/FluentCMS.Core/Jobs/EngineJobs.cs diff --git a/src/FluentCMS.Core/CoreServiceCollectionExtensions.cs b/src/FluentCMS.Core/CoreServiceCollectionExtensions.cs index 51de78c58..0e59c1f19 100644 --- a/src/FluentCMS.Core/CoreServiceCollectionExtensions.cs +++ b/src/FluentCMS.Core/CoreServiceCollectionExtensions.cs @@ -3,6 +3,8 @@ using FluentCMS.Core.Checks; using FluentCMS.Core.Desk; using FluentCMS.Core.Extensions; +using FluentCMS.Core.Identity; +using FluentCMS.Core.Jobs; using FluentCMS.Core.Media; using FluentCMS.Core.Models; using FluentCMS.Core.Schema; @@ -41,6 +43,9 @@ public static IServiceCollection AddFluentCmsCore(this IServiceCollection servic // Reads through IChangeSetLists and IEntityFacts, which Infrastructure provides. services.AddScoped(); + // The handlers that run as an engine; a job worker runs any other handler as whoever enqueued its job (ADR-0012). + services.AddSingleton(new EngineJobs(new Dictionary())); + return services; } } diff --git a/src/FluentCMS.Core/Identity/Initiator.cs b/src/FluentCMS.Core/Identity/Initiator.cs index a2f35f81a..a20e663d3 100644 --- a/src/FluentCMS.Core/Identity/Initiator.cs +++ b/src/FluentCMS.Core/Identity/Initiator.cs @@ -16,7 +16,8 @@ public sealed record AgentInitiator(Guid CredentialId, string DisplayName) : Ini // make only the moves its component owns and read content, never write it. public sealed record SystemInitiator : Initiator { - public SystemInitiator(string component) + // Only Core names its engines, so no other code makes up one to act as. + internal SystemInitiator(string component) : base(component) { if (!SchemaNames.IsSlug(component)) diff --git a/src/FluentCMS.Core/Jobs/EngineJobs.cs b/src/FluentCMS.Core/Jobs/EngineJobs.cs new file mode 100644 index 000000000..009a2afa4 --- /dev/null +++ b/src/FluentCMS.Core/Jobs/EngineJobs.cs @@ -0,0 +1,35 @@ +using System.Collections.Frozen; +using FluentCMS.Core.Identity; +using FluentCMS.Core.Results; + +namespace FluentCMS.Core.Jobs; + +// The job handlers that run as an engine, each with the one engine it runs as (ADR-0012). Only Core lists them, so no other +// handler, a plugin's included (EXT-03), borrows an engine's permissions by naming it in RunAs. +public sealed class EngineJobs +{ + private readonly FrozenDictionary _engines; + + internal EngineJobs(IReadOnlyDictionary engines) + { + _engines = engines.ToFrozenDictionary(); + } + + // The engine a handler's jobs run as, null to run them as whoever enqueued them, or a failure when it names an engine it is not + // listed for, so the worker fails the job instead of running it as anyone. + public Result RunAs(IJobHandler handler) + { + ArgumentNullException.ThrowIfNull(handler); + + if (handler.RunAs is not { } claimed) + { + return Result.Success(null); + } + + return _engines.TryGetValue(handler.GetType(), out var engine) && engine == claimed + ? Result.Success(claimed) + : Result.Failure( + JobErrorCodes.EngineNotAllowed, + $"The handler of '{handler.Type}' jobs claims to run as the '{claimed.Component}' engine, which only Core's own handlers may."); + } +} diff --git a/src/FluentCMS.Core/Jobs/JobErrorCodes.cs b/src/FluentCMS.Core/Jobs/JobErrorCodes.cs index d2692c21e..a8ed825fe 100644 --- a/src/FluentCMS.Core/Jobs/JobErrorCodes.cs +++ b/src/FluentCMS.Core/Jobs/JobErrorCodes.cs @@ -3,4 +3,5 @@ namespace FluentCMS.Core.Jobs; public static class JobErrorCodes { public const string JobNotFound = "job_not_found"; + public const string EngineNotAllowed = "job_engine_not_allowed"; } diff --git a/src/FluentCMS.Host/Identity/IdentityPasswordReset.cs b/src/FluentCMS.Host/Identity/IdentityPasswordReset.cs index 2b90ba4e8..8978f3bed 100644 --- a/src/FluentCMS.Host/Identity/IdentityPasswordReset.cs +++ b/src/FluentCMS.Host/Identity/IdentityPasswordReset.cs @@ -15,6 +15,7 @@ internal sealed class IdentityPasswordReset( IUnitOfWork unitOfWork, IAuditLog auditLog, IHttpContextAccessor accessor, + ICurrentInitiator currentInitiator, IOptions tokenOptions) : IPasswordReset { public const string LifespanSetting = "PasswordReset:TokenLifespan"; @@ -28,6 +29,14 @@ internal sealed class IdentityPasswordReset( public async Task> IssueAsync(Guid userId, CancellationToken cancellationToken = default) { + // A reset link takes over the account it resets, so only a person issues one, never an agent or an engine (ADR-0012). + if (currentInitiator.Current is AgentInitiator or SystemInitiator) + { + return Result.Failure( + UserErrorCodes.PeopleOnly, + $"Only a person can issue a password reset link; {currentInitiator.Current.DisplayName} cannot."); + } + var users = signInManager.UserManager; var user = await users.FindByIdAsync(userId.ToString()); if (user is null) diff --git a/tests/FluentCMS.Tests/Identity/SystemInitiatorTests.cs b/tests/FluentCMS.Tests/Identity/SystemInitiatorTests.cs index 0786647d9..aaa4e1e62 100644 --- a/tests/FluentCMS.Tests/Identity/SystemInitiatorTests.cs +++ b/tests/FluentCMS.Tests/Identity/SystemInitiatorTests.cs @@ -15,11 +15,12 @@ public sealed class SystemInitiatorTests [Fact] public void A_component_has_one_stable_id_and_the_audit_log_names_it_system() { - Assert.Equal(SystemInitiator.Checks.Id, new SystemInitiator("checks").Id); + Assert.Equal(SystemInitiator.Checks, InitiatorKinds.RestoreSystem("checks", SystemInitiator.Checks.Id)); Assert.NotEqual(SystemInitiator.Checks.Id, SystemInitiator.Retention.Id); Assert.Equal(("checks", "checks"), (SystemInitiator.Checks.Component, SystemInitiator.Checks.DisplayName)); Assert.Equal(("system", SystemInitiator.Retention.Id), InitiatorKinds.Describe(SystemInitiator.Retention)); - Assert.Throws(() => new SystemInitiator("Check Engine")); + Assert.Throws(() => InitiatorKinds.RestoreSystem("Check Engine", SystemInitiator.Checks.Id)); + Assert.DoesNotContain(typeof(SystemInitiator).GetConstructors(), constructor => constructor.GetParameters() is [{ ParameterType: var type }] && type == typeof(string)); } // CHG-01: a change set always has a person or an agent behind it. From f75bfa113914ae1a42b0de38543e94b8fd67ef33 Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Mon, 19 Oct 2026 03:17:29 -0400 Subject: [PATCH 2/6] Refuse reset links to engines and agents, and test the remaining refusals --- tests/FluentCMS.IntegrationTests/AdminHost.cs | 26 ++++++-- .../Identity/PasswordResetTests.cs | 15 +++++ .../Identity/SystemInitiatorTests.cs | 61 +++++++++++++++++++ tests/FluentCMS.Tests/Jobs/EngineJobsTests.cs | 42 +++++++++++++ 4 files changed, 140 insertions(+), 4 deletions(-) create mode 100644 tests/FluentCMS.Tests/Jobs/EngineJobsTests.cs diff --git a/tests/FluentCMS.IntegrationTests/AdminHost.cs b/tests/FluentCMS.IntegrationTests/AdminHost.cs index 8a4a0e345..208843c06 100644 --- a/tests/FluentCMS.IntegrationTests/AdminHost.cs +++ b/tests/FluentCMS.IntegrationTests/AdminHost.cs @@ -100,14 +100,32 @@ public Task AsUserAsync(Guid userId, Func> action) return AsUserAsync(userId, action); } - public async Task AsUserAsync(Guid userId, Func> action) + public Task AsUserAsync(Guid userId, Func> action) + where TService : notnull + { + return AsAsync( + new ClaimsIdentity( + [new Claim(ClaimTypes.NameIdentifier, userId.ToString()), new Claim(FluentCmsClaimTypes.DisplayName, "Test administrator")], + authenticationType: "test"), + action); + } + + // As a request an agent credential signed would, for what a test drives without a tool. + public Task AsAgentAsync(Guid credentialId, Func> action) + where TService : notnull + { + return AsAsync( + new ClaimsIdentity( + [new Claim(FluentCmsClaimTypes.AgentCredentialId, credentialId.ToString()), new Claim(FluentCmsClaimTypes.DisplayName, "Test agent")], + authenticationType: "test"), + action); + } + + private async Task AsAsync(ClaimsIdentity identity, Func> action) where TService : notnull { var accessor = App.Services.GetRequiredService(); await using var scope = App.Services.CreateAsyncScope(); - var identity = new ClaimsIdentity( - [new Claim(ClaimTypes.NameIdentifier, userId.ToString()), new Claim(FluentCmsClaimTypes.DisplayName, "Test administrator")], - authenticationType: "test"); accessor.HttpContext = new DefaultHttpContext { RequestServices = scope.ServiceProvider, User = new ClaimsPrincipal(identity) }; try diff --git a/tests/FluentCMS.IntegrationTests/Identity/PasswordResetTests.cs b/tests/FluentCMS.IntegrationTests/Identity/PasswordResetTests.cs index ee2d11916..aea65ccfb 100644 --- a/tests/FluentCMS.IntegrationTests/Identity/PasswordResetTests.cs +++ b/tests/FluentCMS.IntegrationTests/Identity/PasswordResetTests.cs @@ -97,6 +97,21 @@ public async Task An_expired_link_is_refused_and_leaves_the_password_unchanged() Assert.DoesNotContain(await AuditRowsAsync(host), row => row.Operation == "user.password_reset"); } + // ADR-0012: a reset link takes over the account it resets, so only a person issues one; an agent is refused, as an engine is. + [Fact] + public async Task Only_a_person_issues_a_reset_link() + { + using var install = new TemporaryInstall(); + await using var host = await AdminHost.StartAsync(install); + var ownerId = await host.CreateFirstAdministratorAsync(); + var editorId = await host.AsUserAsync(ownerId, async directory => (await directory.CreateAsync(Editor)).Value); + + var refused = await host.AsAgentAsync>(Guid.NewGuid(), reset => reset.IssueAsync(editorId)); + + Assert.Equal(UserErrorCodes.PeopleOnly, Assert.Single(refused.Errors).Code); + Assert.DoesNotContain(await AuditRowsAsync(host), row => row.Operation == "user.password_reset_issued"); + } + [Fact] public async Task A_disabled_user_gets_no_link_and_cannot_use_one_issued_before() { diff --git a/tests/FluentCMS.IntegrationTests/Identity/SystemInitiatorTests.cs b/tests/FluentCMS.IntegrationTests/Identity/SystemInitiatorTests.cs index 5f36aa095..a4be0ff49 100644 --- a/tests/FluentCMS.IntegrationTests/Identity/SystemInitiatorTests.cs +++ b/tests/FluentCMS.IntegrationTests/Identity/SystemInitiatorTests.cs @@ -3,6 +3,7 @@ using System.Text.Json.Nodes; using FluentCMS.Core.Agents; using FluentCMS.Core.Audit; +using FluentCMS.Core.ChangeSets; using FluentCMS.Core.Checks; using FluentCMS.Core.Extensions; using FluentCMS.Core.Identity; @@ -78,6 +79,35 @@ public async Task An_engine_cannot_write_or_activate_a_check_issue_a_credential_ Assert.Equal(UserErrorCodes.PeopleOnly, Assert.Single(user.Errors).Code); } + // An engine is refused the other moves only people make: grouping an operation into a session's set, disabling and enabling a + // user, revoking a credential and deactivating a check. Each refusal comes before anything is read, so no item needs to exist. + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task An_engine_cannot_group_a_session_manage_users_revoke_a_credential_or_deactivate_a_check(string provider) + { + await using var scenario = await CheckScenario.CreateAsync(provider); + await scenario.CreateAsync("no-superlatives", CheckScenario.NoSuperlatives()); + scenario.Initiator = SystemInitiator.Checks; + await using var scope = scenario.Services.CreateAsyncScope(); + var services = scope.ServiceProvider; + + var grouped = await services.GetRequiredService().AddToSessionAsync( + "tidy-up", + new NewChangeSet("Tidy up.", []), + ChangeOperation.Create(OperationKind.Create, ItemKind.Entry, Guid.NewGuid(), null, 1).Value); + var disabled = await services.GetRequiredService().DisableAsync(Guid.NewGuid()); + var enabled = await services.GetRequiredService().EnableAsync(Guid.NewGuid()); + var revoked = await services.GetRequiredService().RevokeAsync(Guid.NewGuid()); + var deactivated = await scenario.RunAsync(directory => directory.DeactivateAsync("no-superlatives")); + + Assert.Equal(ChangeSetErrorCodes.InitiatorNotAllowed, Assert.Single(grouped.Errors).Code); + Assert.Equal(UserErrorCodes.PeopleOnly, Assert.Single(disabled.Errors).Code); + Assert.Equal(UserErrorCodes.PeopleOnly, Assert.Single(enabled.Errors).Code); + Assert.Equal(AgentCredentialErrorCodes.PeopleOnly, Assert.Single(revoked.Errors).Code); + Assert.Equal(CheckErrorCodes.PeopleOnly, Assert.Single(deactivated.Errors).Code); + } + // AUD-04: the retention purge runs as the retention engine, and no other engine may purge. [Theory] [InlineData(DatabaseProviders.Sqlite)] @@ -165,6 +195,37 @@ public async Task An_engine_is_stored_by_its_component_whatever_its_display_name Assert.Equal("checks", Assert.IsType(read.Initiator).Component); Assert.Throws(() => InitiatorKinds.RestoreSystem("checks", SystemInitiator.Retention.Id)); Assert.Throws(() => InitiatorKinds.RestoreSystem("Not A Component", Guid.NewGuid())); + + // Read back through the log, a row whose id is not its component's is a fault, not an engine. + await QueryAsync(scenario, context => context.AuditEvents + .Where(row => row.Operation == "checks.renamed") + .ExecuteUpdateAsync(row => row.SetProperty(stored => stored.InitiatorId, SystemInitiator.Retention.Id))); + await Assert.ThrowsAsync(() => scenario.ReadAsync(new AuditQuery { Subject = Set })); + } + + // A job row whose engine id is not its component's is refused when it is read, as an audit row is. + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task A_job_stored_with_another_engine_s_id_is_refused_when_it_is_read(string provider) + { + await using var scenario = await CheckScenario.CreateAsync(provider); + scenario.Initiator = SystemInitiator.Retention; + await using var scope = scenario.Services.CreateAsyncScope(); + var services = scope.ServiceProvider; + Job enqueued; + await using (var work = await services.GetRequiredService().BeginAsync()) + { + enqueued = await services.GetRequiredService().EnqueueAsync(new NewJob("retention.purge", JsonDocument.Parse("{}").RootElement.Clone())); + await work.CommitAsync(); + } + + await scenario.QueryAsync(context => context.Jobs + .Where(row => row.Id == enqueued.Id) + .ExecuteUpdateAsync(row => row.SetProperty(stored => stored.InitiatorId, SystemInitiator.Checks.Id))); + + await using var reading = scenario.Services.CreateAsyncScope(); + await Assert.ThrowsAsync(() => reading.ServiceProvider.GetRequiredService().GetAsync(enqueued.Id)); } private static async Task QueryAsync(AuditScenario scenario, Func> query) diff --git a/tests/FluentCMS.Tests/Jobs/EngineJobsTests.cs b/tests/FluentCMS.Tests/Jobs/EngineJobsTests.cs new file mode 100644 index 000000000..14fa77a6c --- /dev/null +++ b/tests/FluentCMS.Tests/Jobs/EngineJobsTests.cs @@ -0,0 +1,42 @@ +using FluentCMS.Core; +using FluentCMS.Core.Identity; +using FluentCMS.Core.Jobs; +using FluentCMS.Core.Results; +using Microsoft.Extensions.DependencyInjection; + +namespace FluentCMS.Tests.Jobs; + +// ADR-0012, EXT-03: a handler runs as an engine only if Core lists it as that engine's; naming one in RunAs is not enough. +public sealed class EngineJobsTests +{ + [Fact] + public void A_handler_that_names_no_engine_runs_as_whoever_enqueued_its_job() + { + Assert.Null(Engines().RunAs(new Handler(null)).Value); + } + + [Fact] + public void A_handler_Core_does_not_list_cannot_claim_an_engine() + { + var claimed = Engines().RunAs(new Handler(SystemInitiator.Checks)); + + var error = Assert.Single(claimed.Errors); + Assert.Equal(JobErrorCodes.EngineNotAllowed, error.Code); + Assert.Contains("'checks' engine", error.Message); + } + + private static EngineJobs Engines() + { + using var provider = new ServiceCollection().AddFluentCmsCore().BuildServiceProvider(); + return provider.GetRequiredService(); + } + + private sealed class Handler(SystemInitiator? runAs) : IJobHandler + { + public string Type => "sample.claims"; + + public SystemInitiator? RunAs => runAs; + + public Task RunAsync(JobContext context, CancellationToken cancellationToken) => Task.FromResult(Result.Success()); + } +} From 6a3bd67e742c0aa595a117aeb21d63f8d6538f3f Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Mon, 19 Oct 2026 04:43:20 -0400 Subject: [PATCH 3/6] Let only Core's listed handlers run as an engine --- CONTRIBUTING.md | 2 +- ...system-initiator-for-engine-driven-actions.md | 5 +++-- .../CoreServiceCollectionExtensions.cs | 3 ++- src/FluentCMS.Host/Jobs/JobQueueWorker.cs | 12 +++++++++++- .../Identity/SystemInitiatorTests.cs | 16 ++++++++++------ tests/FluentCMS.Tests/Jobs/EngineJobsTests.cs | 9 +++++++++ 6 files changed, 36 insertions(+), 11 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f8b90d27d..90afbc212 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -104,7 +104,7 @@ The admin's `/audit` page offers the export and an item's history. A page cannot ## Background jobs -Work that outlives a request, such as a bulk operation or a check run, is a job in the `Jobs` table (ADR-0010). Enqueue it with `IJobQueue.EnqueueAsync(new NewJob(type, payload) { Subject = ..., RunAt = ..., MaxAttempts = ... })` inside the unit of work of the change that needs it, so the job commits with the change or not at all; outside a unit of work it throws, as the audit log does. The job records the current initiator, and its handler runs as them, so what it changes names whoever asked for it. A handler for work no person or agent does, such as the check run a submission starts, names a `SystemInitiator` in `RunAs`, and its jobs run as that engine whoever enqueued them, while the job still records who did (ADR-0012). `IJobQueue.GetAsync` and `ListForSubjectAsync` read a job's state (`queued`, `running`, `succeeded`, `failed` or `dead`), attempts, progress and last error. +Work that outlives a request, such as a bulk operation or a check run, is a job in the `Jobs` table (ADR-0010). Enqueue it with `IJobQueue.EnqueueAsync(new NewJob(type, payload) { Subject = ..., RunAt = ..., MaxAttempts = ... })` inside the unit of work of the change that needs it, so the job commits with the change or not at all; outside a unit of work it throws, as the audit log does. The job records the current initiator, and its handler runs as them, so what it changes names whoever asked for it. A handler for work no person or agent does, such as the check run a submission starts, names a `SystemInitiator` in `RunAs`, and its jobs run as that engine whoever enqueued them, while the job still records who did (ADR-0012). Core lists each such handler with its engine in `EngineJobs`; the worker fails, without running it, a job whose handler names an engine it is not listed for, so a plugin's handler always runs as whoever enqueued its jobs. `IJobQueue.GetAsync` and `ListForSubjectAsync` read a job's state (`queued`, `running`, `succeeded`, `failed` or `dead`), attempts, progress and last error. A handler implements `IJobHandler` (an extension point) with the type name its jobs store, which never changes once released. It reads the payload from `JobContext.Job`, resolves services from `JobContext.Services`, a scope of its own, and reports progress with `ReportProgressAsync`, which also keeps its lease; when that returns false another worker has the job, and the handler stops. A returned failure ends the job as `failed`. An exception retries it after `JobQueue__RetryDelay`, doubled for each attempt up to `JobQueue__MaxRetryDelay`, and after its last attempt the job is `dead`. Work in a handler therefore has to be safe to run again. diff --git a/docs/adr/0012-system-initiator-for-engine-driven-actions.md b/docs/adr/0012-system-initiator-for-engine-driven-actions.md index 30458360b..33982a971 100644 --- a/docs/adr/0012-system-initiator-for-engine-driven-actions.md +++ b/docs/adr/0012-system-initiator-for-engine-driven-actions.md @@ -21,10 +21,11 @@ These run inside a request or as a job (ADR-0010), and a job runs as whoever enq - It is never a change set's initiator. `ChangeSet.Open` refuses it, and so do session grouping and revert, which open sets through it. - `AgentPermissions` treats it as not an agent and lets it make only the moves its component owns. `checks` moves a set from checks running to checks failed or awaiting review; routing will add auto-publish. - It reads content, and never creates, edits or deletes content, opens or adds to a change set, or calls a tool. - - What only a person may do refuses a system initiator as it refuses an agent: managing users, issuing credentials, and activating, writing or revising checks. + - What only a person may do refuses a system initiator as it refuses an agent: managing users and issuing their reset links, issuing and revoking credentials, and activating, deactivating, writing or revising checks. - Maintenance names what it may do: the prompt purge accepts `retention` as well as a person. - - It spends no model budget of its own. A model call it makes for an agent's change set is charged to that agent (SEC-04). + - It spends no model budget of its own. A model call it makes for a change set is made on behalf of whoever opened the set, and an agent's counts against that agent's cap (SEC-04). - A job type can run as a system initiator, whoever enqueued the job (`IJobHandler.RunAs`). The job still records who enqueued it. The check run is enqueued in the submitting agent's call and runs as `checks`. The job type decides this, not the code that enqueues it, so no caller can borrow an engine's permissions. +- Only Core names its engines and the handlers that run as them. `SystemInitiator` has no public constructor, and Core lists each handler that may run as an engine with that engine (`EngineJobs`). The worker fails a job whose handler claims an engine it is not listed for, without running it. So a plugin's handler (EXT-03) runs as whoever enqueued its jobs, never as an engine, whatever its `RunAs` says. Inside a request, only Core opens a scope that acts as an engine, such as the check run of a small set. ## Consequences diff --git a/src/FluentCMS.Core/CoreServiceCollectionExtensions.cs b/src/FluentCMS.Core/CoreServiceCollectionExtensions.cs index bb0760025..7a37fb91a 100644 --- a/src/FluentCMS.Core/CoreServiceCollectionExtensions.cs +++ b/src/FluentCMS.Core/CoreServiceCollectionExtensions.cs @@ -1,5 +1,6 @@ using FluentCMS.Core.Agents; using FluentCMS.Core.Audit; +using FluentCMS.Core.Builtin.Jobs; using FluentCMS.Core.Checks; using FluentCMS.Core.Desk; using FluentCMS.Core.Extensions; @@ -47,7 +48,7 @@ public static IServiceCollection AddFluentCmsCore(this IServiceCollection servic services.AddScoped(); // The handlers that run as an engine; a job worker runs any other handler as whoever enqueued its job (ADR-0012). - services.AddSingleton(new EngineJobs(new Dictionary())); + services.AddSingleton(new EngineJobs(new Dictionary { [typeof(CheckRunJob)] = SystemInitiator.Checks })); return services; } diff --git a/src/FluentCMS.Host/Jobs/JobQueueWorker.cs b/src/FluentCMS.Host/Jobs/JobQueueWorker.cs index 56b641ecb..24d50d652 100644 --- a/src/FluentCMS.Host/Jobs/JobQueueWorker.cs +++ b/src/FluentCMS.Host/Jobs/JobQueueWorker.cs @@ -74,7 +74,17 @@ private async Task RunNextAsync(string owner, JobQueueOptions settings, Ca return true; } - runScope.ServiceProvider.GetRequiredService().Initiator = handler.RunAs ?? job.Initiator; + // Only a handler Core lists runs as an engine; one that claims an engine it is not listed for fails, run as no one (ADR-0012). + var runAs = runScope.ServiceProvider.GetRequiredService().RunAs(handler); + if (!runAs.IsSuccess) + { + var refused = runAs.Errors[0]; + logger.LogWarning("Job {JobId} of the type {JobType} was not run: {Reason}", job.Id, job.Type, refused.Message); + await leases.FailAsync(job.Id, owner, $"{refused.Code}: {refused.Message}", stoppingToken); + return true; + } + + runScope.ServiceProvider.GetRequiredService().Initiator = runAs.Value ?? job.Initiator; var context = new JobContext( job, diff --git a/tests/FluentCMS.IntegrationTests/Identity/SystemInitiatorTests.cs b/tests/FluentCMS.IntegrationTests/Identity/SystemInitiatorTests.cs index a4be0ff49..0f63460e0 100644 --- a/tests/FluentCMS.IntegrationTests/Identity/SystemInitiatorTests.cs +++ b/tests/FluentCMS.IntegrationTests/Identity/SystemInitiatorTests.cs @@ -150,10 +150,10 @@ public async Task A_job_an_engine_enqueues_is_stored_as_that_engine(string provi Assert.Equal(SystemInitiator.Retention, (await services.GetRequiredService().GetAsync(enqueued.Id)).Value.Initiator); } - // A submission enqueues the check run in the agent's call; the job type, not whoever enqueued it, decides that it runs as the - // check engine, and the job still records who enqueued it. + // EXT-03: a handler Core does not list cannot borrow an engine's permissions by naming it in RunAs; its job fails without running, + // and says why. The check run, which Core lists, runs as the check engine (ChangeSetChecksTests). [Fact] - public async Task The_worker_runs_a_job_as_the_engine_its_handler_names() + public async Task A_handler_that_claims_an_engine_it_is_not_listed_for_fails_its_job_unrun() { using var install = new TemporaryInstall(); await using var host = await AdminHost.StartAsync( @@ -171,13 +171,17 @@ public async Task The_worker_runs_a_job_as_the_engine_its_handler_names() }); var deadline = DateTime.UtcNow.AddSeconds(30); - while (!EngineHandler.RanAs.ContainsKey(job.Id) && DateTime.UtcNow < deadline) + Job read; + do { await Task.Delay(50); + read = await host.AsUserAsync(administrator, async queue => (await queue.GetAsync(job.Id)).Value); } + while (read.State is JobState.Queued or JobState.Running && DateTime.UtcNow < deadline); - Assert.Equal(SystemInitiator.Checks, EngineHandler.RanAs[job.Id]); - Assert.IsType(job.Initiator); + Assert.Equal(JobState.Failed, read.State); + Assert.StartsWith(JobErrorCodes.EngineNotAllowed, read.LastError); + Assert.False(EngineHandler.RanAs.ContainsKey(job.Id)); } // A component's name and id are stored together, and a row whose id is not its component's is refused as a fault. diff --git a/tests/FluentCMS.Tests/Jobs/EngineJobsTests.cs b/tests/FluentCMS.Tests/Jobs/EngineJobsTests.cs index 14fa77a6c..f49ae293c 100644 --- a/tests/FluentCMS.Tests/Jobs/EngineJobsTests.cs +++ b/tests/FluentCMS.Tests/Jobs/EngineJobsTests.cs @@ -15,6 +15,15 @@ public void A_handler_that_names_no_engine_runs_as_whoever_enqueued_its_job() Assert.Null(Engines().RunAs(new Handler(null)).Value); } + [Fact] + public void The_check_run_runs_as_the_check_engine() + { + using var provider = new ServiceCollection().AddFluentCmsCore().BuildServiceProvider(); + var checkRun = provider.GetServices().Single(handler => handler.Type == "checks.run"); + + Assert.Equal(SystemInitiator.Checks, provider.GetRequiredService().RunAs(checkRun).Value); + } + [Fact] public void A_handler_Core_does_not_list_cannot_claim_an_engine() { From 1b4ac676f80de63d520bc6bfdff9d9098fb960ec Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Mon, 19 Oct 2026 05:30:48 -0400 Subject: [PATCH 4/6] Keep engines from passing on through jobs, and read back only known engines --- CONTRIBUTING.md | 2 +- ...tem-initiator-for-engine-driven-actions.md | 5 +- src/FluentCMS.Core/Audit/InitiatorKinds.cs | 15 +- src/FluentCMS.Core/Identity/Initiator.cs | 3 + src/FluentCMS.Core/Jobs/EngineJobs.cs | 24 +-- src/FluentCMS.Host/Jobs/JobQueueWorker.cs | 4 +- tests/FluentCMS.IntegrationTests/AdminHost.cs | 26 +--- .../Identity/PasswordResetTests.cs | 15 -- .../Identity/SystemInitiatorTests.cs | 137 ++++++++++++++++-- .../Identity/SystemInitiatorTests.cs | 1 + tests/FluentCMS.Tests/Jobs/EngineJobsTests.cs | 16 +- 11 files changed, 168 insertions(+), 80 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a6eb161c9..d5a659be4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -104,7 +104,7 @@ The admin's `/audit` page offers the export and an item's history. A page cannot ## Background jobs -Work that outlives a request, such as a bulk operation or a check run, is a job in the `Jobs` table (ADR-0010). Enqueue it with `IJobQueue.EnqueueAsync(new NewJob(type, payload) { Subject = ..., RunAt = ..., MaxAttempts = ... })` inside the unit of work of the change that needs it, so the job commits with the change or not at all; outside a unit of work it throws, as the audit log does. The job records the current initiator, and its handler runs as them, so what it changes names whoever asked for it. A handler for work no person or agent does, such as the check run a submission starts, names a `SystemInitiator` in `RunAs`, and its jobs run as that engine whoever enqueued them, while the job still records who did (ADR-0012). Core lists each such handler with its engine in `EngineJobs`; the worker fails, without running it, a job whose handler names an engine it is not listed for, so a plugin's handler always runs as whoever enqueued its jobs. `IJobQueue.GetAsync` and `ListForSubjectAsync` read a job's state (`queued`, `running`, `succeeded`, `failed` or `dead`), attempts, progress and last error. +Work that outlives a request, such as a bulk operation or a check run, is a job in the `Jobs` table (ADR-0010). Enqueue it with `IJobQueue.EnqueueAsync(new NewJob(type, payload) { Subject = ..., RunAt = ..., MaxAttempts = ... })` inside the unit of work of the change that needs it, so the job commits with the change or not at all; outside a unit of work it throws, as the audit log does. The job records the current initiator, and its handler runs as them, so what it changes names whoever asked for it. A handler for work no person or agent does, such as the check run a submission starts, names a `SystemInitiator` in `RunAs`, and its jobs run as that engine whoever enqueued them, while the job still records who did (ADR-0012). Core lists each such handler with its engine in `EngineJobs`; the worker fails, without running it, a job whose handler names an engine it is not listed for, and a job an engine enqueued for a handler not listed for that engine, so a plugin's handler never runs as an engine. `IJobQueue.GetAsync` and `ListForSubjectAsync` read a job's state (`queued`, `running`, `succeeded`, `failed` or `dead`), attempts, progress and last error. A handler implements `IJobHandler` (an extension point) with the type name its jobs store, which never changes once released. It reads the payload from `JobContext.Job`, resolves services from `JobContext.Services`, a scope of its own, and reports progress with `ReportProgressAsync`, which also keeps its lease; when that returns false another worker has the job, and the handler stops. A returned failure ends the job as `failed`. An exception retries it after `JobQueue__RetryDelay`, doubled for each attempt up to `JobQueue__MaxRetryDelay`, and after its last attempt the job is `dead`. Work in a handler therefore has to be safe to run again. diff --git a/docs/adr/0012-system-initiator-for-engine-driven-actions.md b/docs/adr/0012-system-initiator-for-engine-driven-actions.md index 33982a971..33c34f1bb 100644 --- a/docs/adr/0012-system-initiator-for-engine-driven-actions.md +++ b/docs/adr/0012-system-initiator-for-engine-driven-actions.md @@ -21,11 +21,12 @@ These run inside a request or as a job (ADR-0010), and a job runs as whoever enq - It is never a change set's initiator. `ChangeSet.Open` refuses it, and so do session grouping and revert, which open sets through it. - `AgentPermissions` treats it as not an agent and lets it make only the moves its component owns. `checks` moves a set from checks running to checks failed or awaiting review; routing will add auto-publish. - It reads content, and never creates, edits or deletes content, opens or adds to a change set, or calls a tool. - - What only a person may do refuses a system initiator as it refuses an agent: managing users and issuing their reset links, issuing and revoking credentials, and activating, deactivating, writing or revising checks. + - What only a person may do refuses a system initiator as it refuses an agent: managing users and issuing their reset links, issuing and revoking credentials, and activating or deactivating checks. An engine cannot write or revise a check either, though an agent may propose one. - Maintenance names what it may do: the prompt purge accepts `retention` as well as a person. - It spends no model budget of its own. A model call it makes for a change set is made on behalf of whoever opened the set, and an agent's counts against that agent's cap (SEC-04). - A job type can run as a system initiator, whoever enqueued the job (`IJobHandler.RunAs`). The job still records who enqueued it. The check run is enqueued in the submitting agent's call and runs as `checks`. The job type decides this, not the code that enqueues it, so no caller can borrow an engine's permissions. -- Only Core names its engines and the handlers that run as them. `SystemInitiator` has no public constructor, and Core lists each handler that may run as an engine with that engine (`EngineJobs`). The worker fails a job whose handler claims an engine it is not listed for, without running it. So a plugin's handler (EXT-03) runs as whoever enqueued its jobs, never as an engine, whatever its `RunAs` says. Inside a request, only Core opens a scope that acts as an engine, such as the check run of a small set. +- Only Core names its engines and the handlers that run as them. `SystemInitiator` has no public constructor, a stored row reads back only as one of the product's engines, and Core lists each handler that may run as an engine with that engine (`EngineJobs`). The worker fails, without running it, a job whose handler claims an engine it is not listed for, and a job an engine enqueued for a handler not listed for that engine. So a plugin's job handler (EXT-03) never runs as an engine, whatever its `RunAs` says or whoever enqueued its job. Inside a request, only Core opens a scope that acts as an engine, such as the check run of a small set. +- Check types run inside the check engine's scope, a plugin's as well as Core's, so the content they read is read as the engine and what they could do is what the engine may. The engine's limits above bound them; per-plugin permissions come with the plugin SDK (EXT-03). ## Consequences diff --git a/src/FluentCMS.Core/Audit/InitiatorKinds.cs b/src/FluentCMS.Core/Audit/InitiatorKinds.cs index ba9dfef78..13a4b9e09 100644 --- a/src/FluentCMS.Core/Audit/InitiatorKinds.cs +++ b/src/FluentCMS.Core/Audit/InitiatorKinds.cs @@ -29,17 +29,12 @@ public static string NameOf(Initiator initiator) return initiator is SystemInitiator system ? system.Component : initiator.DisplayName; } - // A stored system initiator, refused when its id is not the one its component derives, as a stored kind nobody knows is. + // A stored system initiator: one of the product's engines, with the id its component derives. Any other is refused as a fault, + // as a stored kind nobody knows is, so reading a row never makes up an engine. public static SystemInitiator RestoreSystem(string component, Guid id) { - try - { - var system = new SystemInitiator(component); - return system.Id == id ? system : throw new InvalidOperationException($"The system component '{component}' is stored with an id that is not its own."); - } - catch (ArgumentException exception) - { - throw new InvalidOperationException($"'{component}' is stored as a system component, and it is not a component name.", exception); - } + var system = SystemInitiator.Engines.FirstOrDefault(engine => engine.Component == component) + ?? throw new InvalidOperationException($"'{component}' is stored as a system component, and the product has no engine of that name."); + return system.Id == id ? system : throw new InvalidOperationException($"The system component '{component}' is stored with an id that is not its own."); } } diff --git a/src/FluentCMS.Core/Identity/Initiator.cs b/src/FluentCMS.Core/Identity/Initiator.cs index a20e663d3..4086c3d5c 100644 --- a/src/FluentCMS.Core/Identity/Initiator.cs +++ b/src/FluentCMS.Core/Identity/Initiator.cs @@ -35,6 +35,9 @@ internal SystemInitiator(string component) // Maintenance that clears data past its retention, such as stored prompts (AUD-04). public static SystemInitiator Retention { get; } = new("retention"); + // The product's engines; a stored row naming any other is a fault, not an engine. + internal static IReadOnlyList Engines => [Checks, Retention]; + public string Component { get; } // The same for a component every time, so the audit log names one engine one way in every row. diff --git a/src/FluentCMS.Core/Jobs/EngineJobs.cs b/src/FluentCMS.Core/Jobs/EngineJobs.cs index 009a2afa4..15d4f0641 100644 --- a/src/FluentCMS.Core/Jobs/EngineJobs.cs +++ b/src/FluentCMS.Core/Jobs/EngineJobs.cs @@ -15,21 +15,27 @@ internal EngineJobs(IReadOnlyDictionary engines) _engines = engines.ToFrozenDictionary(); } - // The engine a handler's jobs run as, null to run them as whoever enqueued them, or a failure when it names an engine it is not - // listed for, so the worker fails the job instead of running it as anyone. - public Result RunAs(IJobHandler handler) + // The engine a job runs as, null to run it as whoever enqueued it, or a failure, so the worker fails the job instead of running + // it as anyone: when its handler names an engine it is not listed for, or when an engine enqueued it for a handler not listed for + // that engine, as code running inside an engine's scope might, to pass the engine on. + public Result RunAs(IJobHandler handler, Initiator? enqueuedBy) { ArgumentNullException.ThrowIfNull(handler); - if (handler.RunAs is not { } claimed) + var listed = _engines.GetValueOrDefault(handler.GetType()); + if (handler.RunAs is { } claimed) { - return Result.Success(null); + return listed == claimed + ? Result.Success(claimed) + : Result.Failure( + JobErrorCodes.EngineNotAllowed, + $"The handler of '{handler.Type}' jobs claims to run as the '{claimed.Component}' engine, which only Core's own handlers may."); } - return _engines.TryGetValue(handler.GetType(), out var engine) && engine == claimed - ? Result.Success(claimed) - : Result.Failure( + return enqueuedBy is SystemInitiator engine && listed != engine + ? Result.Failure( JobErrorCodes.EngineNotAllowed, - $"The handler of '{handler.Type}' jobs claims to run as the '{claimed.Component}' engine, which only Core's own handlers may."); + $"The '{engine.Component}' engine enqueued a '{handler.Type}' job, and an engine's jobs run only through the handlers Core lists for it.") + : Result.Success(null); } } diff --git a/src/FluentCMS.Host/Jobs/JobQueueWorker.cs b/src/FluentCMS.Host/Jobs/JobQueueWorker.cs index 24d50d652..e308e1852 100644 --- a/src/FluentCMS.Host/Jobs/JobQueueWorker.cs +++ b/src/FluentCMS.Host/Jobs/JobQueueWorker.cs @@ -74,8 +74,8 @@ private async Task RunNextAsync(string owner, JobQueueOptions settings, Ca return true; } - // Only a handler Core lists runs as an engine; one that claims an engine it is not listed for fails, run as no one (ADR-0012). - var runAs = runScope.ServiceProvider.GetRequiredService().RunAs(handler); + // Only a handler Core lists runs as an engine, or a job an engine enqueued; any other claim fails, run as no one (ADR-0012). + var runAs = runScope.ServiceProvider.GetRequiredService().RunAs(handler, job.Initiator); if (!runAs.IsSuccess) { var refused = runAs.Errors[0]; diff --git a/tests/FluentCMS.IntegrationTests/AdminHost.cs b/tests/FluentCMS.IntegrationTests/AdminHost.cs index 208843c06..8a4a0e345 100644 --- a/tests/FluentCMS.IntegrationTests/AdminHost.cs +++ b/tests/FluentCMS.IntegrationTests/AdminHost.cs @@ -100,32 +100,14 @@ public Task AsUserAsync(Guid userId, Func> action) return AsUserAsync(userId, action); } - public Task AsUserAsync(Guid userId, Func> action) - where TService : notnull - { - return AsAsync( - new ClaimsIdentity( - [new Claim(ClaimTypes.NameIdentifier, userId.ToString()), new Claim(FluentCmsClaimTypes.DisplayName, "Test administrator")], - authenticationType: "test"), - action); - } - - // As a request an agent credential signed would, for what a test drives without a tool. - public Task AsAgentAsync(Guid credentialId, Func> action) - where TService : notnull - { - return AsAsync( - new ClaimsIdentity( - [new Claim(FluentCmsClaimTypes.AgentCredentialId, credentialId.ToString()), new Claim(FluentCmsClaimTypes.DisplayName, "Test agent")], - authenticationType: "test"), - action); - } - - private async Task AsAsync(ClaimsIdentity identity, Func> action) + public async Task AsUserAsync(Guid userId, Func> action) where TService : notnull { var accessor = App.Services.GetRequiredService(); await using var scope = App.Services.CreateAsyncScope(); + var identity = new ClaimsIdentity( + [new Claim(ClaimTypes.NameIdentifier, userId.ToString()), new Claim(FluentCmsClaimTypes.DisplayName, "Test administrator")], + authenticationType: "test"); accessor.HttpContext = new DefaultHttpContext { RequestServices = scope.ServiceProvider, User = new ClaimsPrincipal(identity) }; try diff --git a/tests/FluentCMS.IntegrationTests/Identity/PasswordResetTests.cs b/tests/FluentCMS.IntegrationTests/Identity/PasswordResetTests.cs index aea65ccfb..ee2d11916 100644 --- a/tests/FluentCMS.IntegrationTests/Identity/PasswordResetTests.cs +++ b/tests/FluentCMS.IntegrationTests/Identity/PasswordResetTests.cs @@ -97,21 +97,6 @@ public async Task An_expired_link_is_refused_and_leaves_the_password_unchanged() Assert.DoesNotContain(await AuditRowsAsync(host), row => row.Operation == "user.password_reset"); } - // ADR-0012: a reset link takes over the account it resets, so only a person issues one; an agent is refused, as an engine is. - [Fact] - public async Task Only_a_person_issues_a_reset_link() - { - using var install = new TemporaryInstall(); - await using var host = await AdminHost.StartAsync(install); - var ownerId = await host.CreateFirstAdministratorAsync(); - var editorId = await host.AsUserAsync(ownerId, async directory => (await directory.CreateAsync(Editor)).Value); - - var refused = await host.AsAgentAsync>(Guid.NewGuid(), reset => reset.IssueAsync(editorId)); - - Assert.Equal(UserErrorCodes.PeopleOnly, Assert.Single(refused.Errors).Code); - Assert.DoesNotContain(await AuditRowsAsync(host), row => row.Operation == "user.password_reset_issued"); - } - [Fact] public async Task A_disabled_user_gets_no_link_and_cannot_use_one_issued_before() { diff --git a/tests/FluentCMS.IntegrationTests/Identity/SystemInitiatorTests.cs b/tests/FluentCMS.IntegrationTests/Identity/SystemInitiatorTests.cs index 0f63460e0..3b7d4ffca 100644 --- a/tests/FluentCMS.IntegrationTests/Identity/SystemInitiatorTests.cs +++ b/tests/FluentCMS.IntegrationTests/Identity/SystemInitiatorTests.cs @@ -16,6 +16,7 @@ using FluentCMS.IntegrationTests.Audit; using FluentCMS.IntegrationTests.Checks; using FluentCMS.IntegrationTests.Models; +using FluentCMS.IntegrationTests.Mcp; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; @@ -152,36 +153,93 @@ public async Task A_job_an_engine_enqueues_is_stored_as_that_engine(string provi // EXT-03: a handler Core does not list cannot borrow an engine's permissions by naming it in RunAs; its job fails without running, // and says why. The check run, which Core lists, runs as the check engine (ChangeSetChecksTests). - [Fact] - public async Task A_handler_that_claims_an_engine_it_is_not_listed_for_fails_its_job_unrun() + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task A_handler_that_claims_an_engine_it_is_not_listed_for_fails_its_job_unrun(string provider) { - using var install = new TemporaryInstall(); - await using var host = await AdminHost.StartAsync( - install, - settings: new Dictionary { ["JobQueue:PollingInterval"] = "00:00:00.050" }, + await using var agent = await ConnectedAgent.StartAsync( + provider, + settings: FastQueue, configure: builder => builder.Services.AddFluentCmsExtension()); - var administrator = await host.CreateFirstAdministratorAsync(); - var job = await host.AsUserAsync(administrator, async services => + var job = await agent.AsAgentAsync(services => EnqueueAsync(services, EngineHandler.Name)); + var ended = await EndedAsync(agent, job.Id); + + Assert.Equal(JobState.Failed, ended.State); + Assert.StartsWith(JobErrorCodes.EngineNotAllowed, ended.LastError); + Assert.False(EngineHandler.RanAs.ContainsKey(job.Id)); + } + + // ADR-0012: a reset link takes over the account it resets, so only a person issues one. Code running inside the check engine's + // scope, as a check type does, is refused, and cannot pass the engine on to a job of a handler Core does not list for it either. + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task Code_running_as_an_engine_cannot_issue_a_reset_link_or_pass_the_engine_to_a_job(string provider) + { + await using var agent = await ConnectedAgent.StartAsync( + provider, + settings: FastQueue, + configure: builder => + { + builder.Services.AddFluentCmsExtension(); + builder.Services.AddFluentCmsExtension(); + }); + await agent.CreateAsPersonAsync("probe"); + var definition = new CheckDefinition( + EngineProbe.Name, + "Probes what the check engine may do.", + JsonSerializer.SerializeToElement(new { }), + [ + new CheckExample("passes", CheckVerdict.Pass, JsonSerializer.SerializeToElement(new { title = "A kettle" }), contentType: "probe"), + new CheckExample("fails", CheckVerdict.Fail, JsonSerializer.SerializeToElement(new { title = "fail" }), contentType: "probe"), + ], + scope: new CheckScope(["probe"])); + await agent.AsPersonAsync(directory => directory.CreateAsync(new NewCheck("engine-probe", definition))); + await agent.AsPersonAsync(directory => directory.ActivateAsync("engine-probe", 1)); + var byAgent = await agent.AsAgentAsync(services => services.GetRequiredService().IssueAsync(Guid.NewGuid())); + + var created = await agent.CallAsync("create_entry", new() { - await using var work = await services.GetRequiredService().BeginAsync(); - var enqueued = await services.GetRequiredService().EnqueueAsync(new NewJob(EngineHandler.Name, JsonDocument.Parse("{}").RootElement.Clone())); - await work.CommitAsync(); - return enqueued; + ["content_type"] = "probe", + ["fields"] = new JsonObject { ["title"] = "A kettle" }, + ["session_id"] = "probe", + ["rationale"] = "Probe the engine.", }); + await agent.CallAsync("submit_change_set", new() { ["id"] = (string?)created["changeSet"]!["id"] }); + var followed = await EndedAsync(agent, Assert.Single(EngineProbe.Enqueued)); + + Assert.Equal(UserErrorCodes.PeopleOnly, Assert.Single(byAgent.Errors).Code); + Assert.Equal(UserErrorCodes.PeopleOnly, Assert.Single(EngineProbe.Refusals)); + Assert.Equal(JobState.Failed, followed.State); + Assert.StartsWith(JobErrorCodes.EngineNotAllowed, followed.LastError); + Assert.Empty(Follower.Ran); + Assert.DoesNotContain(await agent.QueryAsync(context => context.AuditEvents.AsNoTracking().ToListAsync()), row => row.Operation == "user.password_reset_issued"); + } + + private static readonly Dictionary FastQueue = new() { ["JobQueue:PollingInterval"] = "00:00:00.050" }; + + private static async Task EnqueueAsync(IServiceProvider services, string type) + { + await using var work = await services.GetRequiredService().BeginAsync(); + var enqueued = await services.GetRequiredService().EnqueueAsync(new NewJob(type, JsonDocument.Parse("{}").RootElement.Clone())); + await work.CommitAsync(); + return enqueued; + } + private static async Task EndedAsync(ConnectedAgent agent, Guid jobId) + { var deadline = DateTime.UtcNow.AddSeconds(30); Job read; do { await Task.Delay(50); - read = await host.AsUserAsync(administrator, async queue => (await queue.GetAsync(job.Id)).Value); + read = await agent.AsAgentAsync(async services => (await services.GetRequiredService().GetAsync(jobId)).Value); } while (read.State is JobState.Queued or JobState.Running && DateTime.UtcNow < deadline); - Assert.Equal(JobState.Failed, read.State); - Assert.StartsWith(JobErrorCodes.EngineNotAllowed, read.LastError); - Assert.False(EngineHandler.RanAs.ContainsKey(job.Id)); + return read; } // A component's name and id are stored together, and a row whose id is not its component's is refused as a fault. @@ -238,6 +296,53 @@ private static async Task QueryAsync(AuditScenario scenario, Func()); } + // A check type that, run as the check engine, tries what only a person does and to enqueue a job for a handler Core does not list. + private sealed class EngineProbe : ICheckType + { + public const string Name = "sample.engine-probe"; + + public static ConcurrentQueue Refusals { get; } = new(); + + public static ConcurrentQueue Enqueued { get; } = new(); + + string ICheckType.Name => Name; + + public CheckKind Kind => CheckKind.Deterministic; + + public IReadOnlyList ValidateSettings(JsonElement settings) + { + return new CheckSettingsReader(Name, settings).Errors(); + } + + public async Task EvaluateAsync(CheckInput input, CancellationToken cancellationToken) + { + var services = input.Services; + if (services.GetRequiredService().Current is SystemInitiator) + { + var reset = await services.GetRequiredService().IssueAsync(Guid.NewGuid(), cancellationToken); + Refusals.Enqueue(reset.Errors.FirstOrDefault()?.Code ?? "issued"); + Enqueued.Enqueue((await EnqueueAsync(services, Follower.Name)).Id); + } + + return input.Subject.Fields.GetRawText().Contains("fail", StringComparison.Ordinal) ? CheckOutcome.Fail("Remove 'fail'.") : CheckOutcome.Pass(); + } + } + + private sealed class Follower : IJobHandler + { + public const string Name = "test.follower"; + + public static ConcurrentQueue Ran { get; } = new(); + + public string Type => Name; + + public Task RunAsync(JobContext context, CancellationToken cancellationToken) + { + Ran.Enqueue(context.Job.Id); + return Task.FromResult(Result.Success()); + } + } + private sealed class EngineHandler : IJobHandler { public const string Name = "test.engine"; diff --git a/tests/FluentCMS.Tests/Identity/SystemInitiatorTests.cs b/tests/FluentCMS.Tests/Identity/SystemInitiatorTests.cs index aaa4e1e62..217e76049 100644 --- a/tests/FluentCMS.Tests/Identity/SystemInitiatorTests.cs +++ b/tests/FluentCMS.Tests/Identity/SystemInitiatorTests.cs @@ -20,6 +20,7 @@ public void A_component_has_one_stable_id_and_the_audit_log_names_it_system() Assert.Equal(("checks", "checks"), (SystemInitiator.Checks.Component, SystemInitiator.Checks.DisplayName)); Assert.Equal(("system", SystemInitiator.Retention.Id), InitiatorKinds.Describe(SystemInitiator.Retention)); Assert.Throws(() => InitiatorKinds.RestoreSystem("Check Engine", SystemInitiator.Checks.Id)); + Assert.Throws(() => InitiatorKinds.RestoreSystem("billing", Guid.NewGuid())); Assert.DoesNotContain(typeof(SystemInitiator).GetConstructors(), constructor => constructor.GetParameters() is [{ ParameterType: var type }] && type == typeof(string)); } diff --git a/tests/FluentCMS.Tests/Jobs/EngineJobsTests.cs b/tests/FluentCMS.Tests/Jobs/EngineJobsTests.cs index f49ae293c..6d41dceb8 100644 --- a/tests/FluentCMS.Tests/Jobs/EngineJobsTests.cs +++ b/tests/FluentCMS.Tests/Jobs/EngineJobsTests.cs @@ -12,7 +12,7 @@ public sealed class EngineJobsTests [Fact] public void A_handler_that_names_no_engine_runs_as_whoever_enqueued_its_job() { - Assert.Null(Engines().RunAs(new Handler(null)).Value); + Assert.Null(Engines().RunAs(new Handler(null), new UserInitiator(Guid.NewGuid(), "Amir")).Value); } [Fact] @@ -21,19 +21,29 @@ public void The_check_run_runs_as_the_check_engine() using var provider = new ServiceCollection().AddFluentCmsCore().BuildServiceProvider(); var checkRun = provider.GetServices().Single(handler => handler.Type == "checks.run"); - Assert.Equal(SystemInitiator.Checks, provider.GetRequiredService().RunAs(checkRun).Value); + Assert.Equal(SystemInitiator.Checks, provider.GetRequiredService().RunAs(checkRun, new AgentInitiator(Guid.NewGuid(), "agent")).Value); } [Fact] public void A_handler_Core_does_not_list_cannot_claim_an_engine() { - var claimed = Engines().RunAs(new Handler(SystemInitiator.Checks)); + var claimed = Engines().RunAs(new Handler(SystemInitiator.Checks), null); var error = Assert.Single(claimed.Errors); Assert.Equal(JobErrorCodes.EngineNotAllowed, error.Code); Assert.Contains("'checks' engine", error.Message); } + // Code running inside an engine's scope, such as a check type, enqueues as that engine; the job still runs only through a + // handler Core lists for the engine, so the engine is not passed on. + [Fact] + public void A_job_an_engine_enqueued_for_a_handler_Core_does_not_list_is_refused() + { + var passedOn = Engines().RunAs(new Handler(null), SystemInitiator.Checks); + + Assert.Equal(JobErrorCodes.EngineNotAllowed, Assert.Single(passedOn.Errors).Code); + } + private static EngineJobs Engines() { using var provider = new ServiceCollection().AddFluentCmsCore().BuildServiceProvider(); From 840f410f4e11b6b66c5e7cc62e41b92dad7a4899 Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Mon, 19 Oct 2026 05:48:26 -0400 Subject: [PATCH 5/6] Fix the engine probe's name and records --- .../Identity/SystemInitiatorTests.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/FluentCMS.IntegrationTests/Identity/SystemInitiatorTests.cs b/tests/FluentCMS.IntegrationTests/Identity/SystemInitiatorTests.cs index 3b7d4ffca..3232299b1 100644 --- a/tests/FluentCMS.IntegrationTests/Identity/SystemInitiatorTests.cs +++ b/tests/FluentCMS.IntegrationTests/Identity/SystemInitiatorTests.cs @@ -178,6 +178,10 @@ public async Task A_handler_that_claims_an_engine_it_is_not_listed_for_fails_its [PostgresData] public async Task Code_running_as_an_engine_cannot_issue_a_reset_link_or_pass_the_engine_to_a_job(string provider) { + // The probe's records are shared by both providers' runs, which run one after the other. + EngineProbe.Refusals.Clear(); + EngineProbe.Enqueued.Clear(); + Follower.Ran.Clear(); await using var agent = await ConnectedAgent.StartAsync( provider, settings: FastQueue, @@ -299,7 +303,7 @@ private static async Task QueryAsync(AuditScenario scenario, Func Refusals { get; } = new(); From cd1ab02b8977bef17b3bf46dd98efb0a6062d37e Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Mon, 19 Oct 2026 06:15:17 -0400 Subject: [PATCH 6/6] Narrow the engine job rule's wording --- CONTRIBUTING.md | 2 +- docs/adr/0012-system-initiator-for-engine-driven-actions.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d5a659be4..384c2c3ad 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -104,7 +104,7 @@ The admin's `/audit` page offers the export and an item's history. A page cannot ## Background jobs -Work that outlives a request, such as a bulk operation or a check run, is a job in the `Jobs` table (ADR-0010). Enqueue it with `IJobQueue.EnqueueAsync(new NewJob(type, payload) { Subject = ..., RunAt = ..., MaxAttempts = ... })` inside the unit of work of the change that needs it, so the job commits with the change or not at all; outside a unit of work it throws, as the audit log does. The job records the current initiator, and its handler runs as them, so what it changes names whoever asked for it. A handler for work no person or agent does, such as the check run a submission starts, names a `SystemInitiator` in `RunAs`, and its jobs run as that engine whoever enqueued them, while the job still records who did (ADR-0012). Core lists each such handler with its engine in `EngineJobs`; the worker fails, without running it, a job whose handler names an engine it is not listed for, and a job an engine enqueued for a handler not listed for that engine, so a plugin's handler never runs as an engine. `IJobQueue.GetAsync` and `ListForSubjectAsync` read a job's state (`queued`, `running`, `succeeded`, `failed` or `dead`), attempts, progress and last error. +Work that outlives a request, such as a bulk operation or a check run, is a job in the `Jobs` table (ADR-0010). Enqueue it with `IJobQueue.EnqueueAsync(new NewJob(type, payload) { Subject = ..., RunAt = ..., MaxAttempts = ... })` inside the unit of work of the change that needs it, so the job commits with the change or not at all; outside a unit of work it throws, as the audit log does. The job records the current initiator, and its handler runs as them, so what it changes names whoever asked for it. A handler for work no person or agent does, such as the check run a submission starts, names a `SystemInitiator` in `RunAs`, and its jobs run as that engine whoever enqueued them, while the job still records who did (ADR-0012). Core lists each such handler with its engine in `EngineJobs`; the worker fails, without running it, a job whose handler names an engine it is not listed for, and a job an engine enqueued for a handler that names no engine, so a plugin's handler never runs as an engine. `IJobQueue.GetAsync` and `ListForSubjectAsync` read a job's state (`queued`, `running`, `succeeded`, `failed` or `dead`), attempts, progress and last error. A handler implements `IJobHandler` (an extension point) with the type name its jobs store, which never changes once released. It reads the payload from `JobContext.Job`, resolves services from `JobContext.Services`, a scope of its own, and reports progress with `ReportProgressAsync`, which also keeps its lease; when that returns false another worker has the job, and the handler stops. A returned failure ends the job as `failed`. An exception retries it after `JobQueue__RetryDelay`, doubled for each attempt up to `JobQueue__MaxRetryDelay`, and after its last attempt the job is `dead`. Work in a handler therefore has to be safe to run again. diff --git a/docs/adr/0012-system-initiator-for-engine-driven-actions.md b/docs/adr/0012-system-initiator-for-engine-driven-actions.md index 33c34f1bb..2ef11dc0c 100644 --- a/docs/adr/0012-system-initiator-for-engine-driven-actions.md +++ b/docs/adr/0012-system-initiator-for-engine-driven-actions.md @@ -25,7 +25,7 @@ These run inside a request or as a job (ADR-0010), and a job runs as whoever enq - Maintenance names what it may do: the prompt purge accepts `retention` as well as a person. - It spends no model budget of its own. A model call it makes for a change set is made on behalf of whoever opened the set, and an agent's counts against that agent's cap (SEC-04). - A job type can run as a system initiator, whoever enqueued the job (`IJobHandler.RunAs`). The job still records who enqueued it. The check run is enqueued in the submitting agent's call and runs as `checks`. The job type decides this, not the code that enqueues it, so no caller can borrow an engine's permissions. -- Only Core names its engines and the handlers that run as them. `SystemInitiator` has no public constructor, a stored row reads back only as one of the product's engines, and Core lists each handler that may run as an engine with that engine (`EngineJobs`). The worker fails, without running it, a job whose handler claims an engine it is not listed for, and a job an engine enqueued for a handler not listed for that engine. So a plugin's job handler (EXT-03) never runs as an engine, whatever its `RunAs` says or whoever enqueued its job. Inside a request, only Core opens a scope that acts as an engine, such as the check run of a small set. +- Only Core names its engines and the handlers that run as them. `SystemInitiator` has no public constructor, a stored row reads back only as one of the product's engines, and Core lists each handler that may run as an engine with that engine (`EngineJobs`). The worker fails, without running it, a job whose handler claims an engine it is not listed for, and a job an engine enqueued for a handler that names no engine. A listed handler runs as its own engine whoever enqueued its job, since the job type decides. So a plugin's job handler (EXT-03) never runs as an engine, whatever its `RunAs` says or whoever enqueued its job. Inside a request, only Core opens a scope that acts as an engine, such as the check run of a small set. - Check types run inside the check engine's scope, a plugin's as well as Core's, so the content they read is read as the engine and what they could do is what the engine may. The engine's limits above bound them; per-plugin permissions come with the plugin SDK (EXT-03). ## Consequences