Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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, 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.

Expand Down
6 changes: 4 additions & 2 deletions docs/adr/0012-system-initiator-for-engine-driven-actions.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +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, 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 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 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, 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

Expand Down
15 changes: 5 additions & 10 deletions src/FluentCMS.Core/Audit/InitiatorKinds.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.");
}
}
6 changes: 6 additions & 0 deletions src/FluentCMS.Core/CoreServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
using FluentCMS.Core.Agents;
using FluentCMS.Core.Audit;
using FluentCMS.Core.Builtin.Jobs;
using FluentCMS.Core.Checks;
using FluentCMS.Core.Content;
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;
Expand Down Expand Up @@ -48,6 +51,9 @@ public static IServiceCollection AddFluentCmsCore(this IServiceCollection servic
// Writes through IEntryDirectory, IContentTypeDirectory and IChangeSetDirectory in one unit of work, which Infrastructure provides.
services.AddScoped<IEntryEdits, EntryEdits>();

// 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<Type, SystemInitiator> { [typeof(CheckRunJob)] = SystemInitiator.Checks }));

return services;
}
}
6 changes: 5 additions & 1 deletion src/FluentCMS.Core/Identity/Initiator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -34,6 +35,9 @@ public 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<SystemInitiator> 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.
Expand Down
41 changes: 41 additions & 0 deletions src/FluentCMS.Core/Jobs/EngineJobs.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
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<Type, SystemInitiator> _engines;

internal EngineJobs(IReadOnlyDictionary<Type, SystemInitiator> engines)
{
_engines = engines.ToFrozenDictionary();
}

// 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<SystemInitiator?> RunAs(IJobHandler handler, Initiator? enqueuedBy)
{
ArgumentNullException.ThrowIfNull(handler);

var listed = _engines.GetValueOrDefault(handler.GetType());
if (handler.RunAs is { } claimed)
{
return listed == claimed
? Result<SystemInitiator?>.Success(claimed)
: Result<SystemInitiator?>.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 enqueuedBy is SystemInitiator engine && listed != engine
? Result<SystemInitiator?>.Failure(
JobErrorCodes.EngineNotAllowed,
$"The '{engine.Component}' engine enqueued a '{handler.Type}' job, and an engine's jobs run only through the handlers Core lists for it.")
: Result<SystemInitiator?>.Success(null);
}
}
1 change: 1 addition & 0 deletions src/FluentCMS.Core/Jobs/JobErrorCodes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}
9 changes: 9 additions & 0 deletions src/FluentCMS.Host/Identity/IdentityPasswordReset.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ internal sealed class IdentityPasswordReset(
IUnitOfWork unitOfWork,
IAuditLog auditLog,
IHttpContextAccessor accessor,
ICurrentInitiator currentInitiator,
IOptions<DataProtectionTokenProviderOptions> tokenOptions) : IPasswordReset
{
public const string LifespanSetting = "PasswordReset:TokenLifespan";
Expand All @@ -28,6 +29,14 @@ internal sealed class IdentityPasswordReset(

public async Task<Result<PasswordResetToken>> 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<PasswordResetToken>.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)
Expand Down
12 changes: 11 additions & 1 deletion src/FluentCMS.Host/Jobs/JobQueueWorker.cs
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,17 @@ private async Task<bool> RunNextAsync(string owner, JobQueueOptions settings, Ca
return true;
}

runScope.ServiceProvider.GetRequiredService<JobRun>().Initiator = handler.RunAs ?? job.Initiator;
// 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<EngineJobs>().RunAs(handler, job.Initiator);
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<JobRun>().Initiator = runAs.Value ?? job.Initiator;

var context = new JobContext(
job,
Expand Down
Loading
Loading