diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 629bc12a5..8dc66c45e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -91,17 +91,17 @@ Migrations apply automatically when the host starts. Every migration needs a wor ## Units of work and the audit log -Every operation that changes data runs inside a unit of work and appends the audit event that describes it from inside the same scope, so the two commit together or not at all (AUD-N1, ADR-0005). `IUnitOfWork.BeginAsync()` returns a scope; `CommitAsync()` saves and commits, and disposing a scope without committing rolls everything back, including saves that Identity makes on its own inside it. Inner scopes join the outer transaction, and an inner scope disposed without committing makes the outer commit fail. `IAuditLog.AppendAsync(new NewAuditEvent("entry.updated") { ... })` stamps the current initiator and time itself, and throws outside a unit of work or when no initiator is known, so a change without provenance cannot exist. +Every operation that changes data runs inside a unit of work and appends the audit event that describes it from inside the same scope, so the two commit together or not at all (AUD-N1, ADR-0005). `IUnitOfWork.BeginAsync()` returns a scope; `CommitAsync()` saves and commits, and disposing a scope without committing rolls everything back, including saves that Identity makes on its own inside it. Inner scopes join the outer transaction, and an inner scope disposed without committing makes the outer commit fail. `IAuditLog.AppendAsync(new NewAuditEvent("entry.updated") { ... })` stamps the current initiator and time itself, and throws outside a unit of work or when no initiator is known, so a change without provenance cannot exist. The initiator is a person, an agent, or a `SystemInitiator` naming a part of the product that acts on its own (ADR-0012): the check engine (`checks`) moving a set out of checks running, or a retention purge (`retention`). A system initiator never opens a change set, reads but never writes content, and makes only the moves `AgentPermissions` lists for its component. Nothing in the code updates or deletes an audit row. On PostgreSQL the `AuditEvents` migration also revokes `UPDATE`, `DELETE` and `TRUNCATE` from `PUBLIC` and from the role that applies it, which owns the table. A superuser bypasses privilege checks, so run the host, which applies migrations at startup, as an ordinary role that owns the database. SQLite has no roles; there the code is the guarantee. -`IAuditReader.Read(AuditQuery)` reads the log back: an optional window from `From` (inclusive) to `To` (exclusive) and an optional `AuditSubject` for one item's history, oldest first with ties broken by id, streamed from the database. With no window and no subject it is the full log. An inverted window or an incomplete subject is a failed `Result`. `IAuditExporter.ExportAsync` writes the same selection to a stream as JSON or CSV while it reads it, after committing an `audit.exported` event that names who exported and, for one item's history, the item; viewing history is a read and records nothing. Both formats carry the same fields under the same names, in this order: `id`, `timestamp`, `operation`, `initiatorKind` (`user` or `agent`), `initiatorId`, `initiatorName`, `agentCredentialId`, `subjectType`, `subjectId`, `model`, `promptHash`, `sources`, `changeSetId` and `previousVersionId`. The JSON is an array of objects with every key present and `null` for an empty field. The CSV follows RFC 4180 with a header row and CRLF line ends, plus three conventions so every value reads back exactly: `sources` is one cell holding a JSON array, an unquoted empty cell is null while a quoted one is an empty string, and a value starting with `=`, `+`, `-`, `@`, a tab, a carriage return or `'` gets one leading `'`, so a spreadsheet does not run it as a formula; a reader drops that one `'`. +`IAuditReader.Read(AuditQuery)` reads the log back: an optional window from `From` (inclusive) to `To` (exclusive) and an optional `AuditSubject` for one item's history, oldest first with ties broken by id, streamed from the database. With no window and no subject it is the full log. An inverted window or an incomplete subject is a failed `Result`. `IAuditExporter.ExportAsync` writes the same selection to a stream as JSON or CSV while it reads it, after committing an `audit.exported` event that names who exported and, for one item's history, the item; viewing history is a read and records nothing. Both formats carry the same fields under the same names, in this order: `id`, `timestamp`, `operation`, `initiatorKind` (`user`, `agent` or `system`), `initiatorId`, `initiatorName`, `agentCredentialId`, `subjectType`, `subjectId`, `model`, `promptHash`, `sources`, `changeSetId` and `previousVersionId`. The JSON is an array of objects with every key present and `null` for an empty field. The CSV follows RFC 4180 with a header row and CRLF line ends, plus three conventions so every value reads back exactly: `sources` is one cell holding a JSON array, an unquoted empty cell is null while a quoted one is an empty string, and a value starting with `=`, `+`, `-`, `@`, a tab, a carriage return or `'` gets one leading `'`, so a spreadsheet does not run it as a formula; a reader drops that one `'`. The admin's `/audit` page offers the export and an item's history. A page cannot answer with a file, so its export form posts to `POST /audit/export` in the host, which maps the form, calls the exporter and streams the result; it needs a signed-in user and an anti-forgery token like every form, and sends a rejected form back to the page. ## 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. `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). `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 new file mode 100644 index 000000000..30458360b --- /dev/null +++ b/docs/adr/0012-system-initiator-for-engine-driven-actions.md @@ -0,0 +1,41 @@ +# ADR-0012: A system initiator for actions the product takes on its own + +- Status: accepted +- Date: 2026-10-19 +- Related: AUD-01, AUD-04, AUD-N2, CHG-01, CHG-04, CHK-03, RTE-01, MCP-06, SEC-02, ADR-0009, ADR-0010 + +## Context + +The audit log names who caused every change, a person or an agent (AUD-01, ADR-0009), and every change set has one of them as its initiator (CHG-01). Some actions have neither behind them: + +- The check engine moves a change set from checks running to checks failed or awaiting review once its checks have run (CHK-03, CHG-04). +- Routing auto-publishes a set that passes on checks (RTE-01, the one exception in MCP-06). +- Maintenance runs on its own, such as clearing stored prompts past their retention (AUD-04, AUD-N2). + +These run inside a request or as a job (ADR-0010), and a job runs as whoever enqueued it. For the check run that is the agent that submitted the set, and agents may not make the checks' moves (MCP-06, SEC-02). + +## Decision + +- A third kind of initiator, `SystemInitiator`, names a part of the product acting on its own by its component: `checks` and `retention`, and `routing` when F-RTE lands. The audit log stores it with the kind `system`, the component as its name, a stable id derived from the component, and no agent credential. +- Its limits are enforced in Core: + - 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. + - 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). +- 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. + +## Consequences + +- The log tells what an agent did apart from what the product did: an agent submits its set, and the check engine, not the agent, moves it on. +- The audit log stores a system initiator by its component and its derived id, and refuses a row where the two disagree, as it refuses an unknown kind. +- Exports and the audit screen show a third initiator kind. Readers of the export handle `system`. +- A new engine moves nothing until its moves are written into `AgentPermissions`, and none writes content. +- AUD-01 and CHG-01 read "agent/user". The system kind covers only engine-driven moves and maintenance, and the maintainer updates that wording in the decision record. + +## Alternatives considered + +- Act as the submitting agent: no new kind, but false provenance. The log would say the agent moved its own set out of checks running, which it is not allowed to do. +- A service user, an Identity account the engine signs in as: a fake person in the user list and in the log, with a password or key to protect, and "human reviewed" claims would become ambiguous. +- No initiator for these actions: the audit log refuses a change without one (AUD-N1), and a gap would hide who did what. diff --git a/docs/adr/README.md b/docs/adr/README.md index c0387a54d..6a4b85048 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -15,5 +15,6 @@ Technical decisions for FluentCMS v2. Product decisions live in `docs/requiremen | [0009](0009-identity-aspnet-core-identity-agents-not-users.md) | Human sign-in with ASP.NET Core Identity; agents are not users | accepted | | [0010](0010-in-database-search-and-job-queue.md) | In-database full-text search and a durable job queue | accepted | | [0011](0011-admin-component-set-and-styling.md) | Admin component set and styling | accepted | +| [0012](0012-system-initiator-for-engine-driven-actions.md) | A system initiator for actions the product takes on its own | accepted | Deferred to their own ADR when the work starts: CI and release pipeline, container image layout. diff --git a/src/FluentCMS.Admin/Components/Pages/AuditLog.razor b/src/FluentCMS.Admin/Components/Pages/AuditLog.razor index b4f75fb17..62f2f53e4 100644 --- a/src/FluentCMS.Admin/Components/Pages/AuditLog.razor +++ b/src/FluentCMS.Admin/Components/Pages/AuditLog.razor @@ -80,6 +80,10 @@ { @L["Agent"] } + else if (auditEvent.Initiator is SystemInitiator) + { + @L["System"] + } else { @L["User"] diff --git a/src/FluentCMS.Core/Agents/AgentPermissionErrorCodes.cs b/src/FluentCMS.Core/Agents/AgentPermissionErrorCodes.cs index 398c1fd55..5781cb42c 100644 --- a/src/FluentCMS.Core/Agents/AgentPermissionErrorCodes.cs +++ b/src/FluentCMS.Core/Agents/AgentPermissionErrorCodes.cs @@ -7,4 +7,5 @@ public static class AgentPermissionErrorCodes public const string SiteOutOfScope = "agent_site_out_of_scope"; public const string NotOwner = "agent_not_change_set_owner"; public const string ScopeDenied = "agent_scope_denied"; + public const string SystemNotPermitted = "system_not_permitted"; } diff --git a/src/FluentCMS.Core/Agents/AgentPermissions.cs b/src/FluentCMS.Core/Agents/AgentPermissions.cs index 435c75ac6..9a6ee3851 100644 --- a/src/FluentCMS.Core/Agents/AgentPermissions.cs +++ b/src/FluentCMS.Core/Agents/AgentPermissions.cs @@ -21,10 +21,28 @@ internal sealed class AgentPermissions( private static readonly TimeSpan Day = TimeSpan.FromDays(1); + // The moves each part of the product owns (ADR-0012); routing adds auto-publish when it lands. Anything else an engine tries is + // refused, so a new engine moves nothing until someone writes down what it owns. + private static readonly Dictionary SystemMoves = new(StringComparer.Ordinal) + { + [SystemInitiator.Checks.Component] = + [ + (ChangeSetState.ChecksRunning, ChangeSetState.ChecksFailed), + (ChangeSetState.ChecksRunning, ChangeSetState.AwaitingReview), + ], + }; + public async Task CheckTransitionAsync(ChangeSet changeSet, ChangeSetState target, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(changeSet); + if (currentInitiator.Current is SystemInitiator system) + { + return SystemMoves.GetValueOrDefault(system.Component, []).Contains((changeSet.State, target)) + ? Result.Success() + : SystemRefused(system, $"move change set {changeSet.Id} from {Describe(changeSet.State)} to {Describe(target)}"); + } + var agent = await AgentAsync(cancellationToken); if (!agent.IsSuccess) { @@ -54,6 +72,11 @@ await RecordAsync( public async Task CheckNewChangeSetAsync(CancellationToken cancellationToken = default) { + if (currentInitiator.Current is SystemInitiator system) + { + return SystemRefused(system, "open a change set"); + } + var agent = await AgentAsync(cancellationToken); if (!agent.IsSuccess) { @@ -86,6 +109,11 @@ public async Task CheckChangeSetSizeAsync(ChangeSet changeSet, int addin { ArgumentNullException.ThrowIfNull(changeSet); + if (currentInitiator.Current is SystemInitiator system) + { + return SystemRefused(system, $"add to change set {changeSet.Id}"); + } + var agent = await AgentAsync(cancellationToken); if (!agent.IsSuccess) { @@ -128,6 +156,12 @@ public async Task CheckContentAsync( { ArgumentNullException.ThrowIfNull(contentType); + // An engine reads what it judges, and never writes content (ADR-0012). + if (currentInitiator.Current is SystemInitiator system) + { + return operation == AgentOperation.Read ? Result.Success() : SystemRefused(system, $"{Verb(operation)} entries of '{contentType}'"); + } + var agent = await AgentAsync(cancellationToken); if (!agent.IsSuccess) { @@ -166,6 +200,11 @@ public async Task CheckDefinitionAsync(string contentType, AgentOperatio { ArgumentNullException.ThrowIfNull(contentType); + if (currentInitiator.Current is SystemInitiator system) + { + return operation == AgentOperation.Read ? Result.Success() : SystemRefused(system, $"{Verb(operation)} the content type '{contentType}'"); + } + var agent = await AgentAsync(cancellationToken); if (!agent.IsSuccess) { @@ -194,6 +233,11 @@ public async Task CheckDefinitionAsync(string contentType, AgentOperatio public async Task> ReadableTypesAsync(CancellationToken cancellationToken = default) { + if (currentInitiator.Current is SystemInitiator) + { + return _ => true; + } + var agent = await AgentAsync(cancellationToken); if (!agent.IsSuccess) { @@ -211,6 +255,11 @@ public async Task> ReadableTypesAsync(CancellationToken cance public async Task CheckToolCallAsync(CancellationToken cancellationToken = default) { + if (currentInitiator.Current is SystemInitiator system) + { + return SystemRefused(system, "call a tool"); + } + var agent = await AgentAsync(cancellationToken); if (!agent.IsSuccess) { @@ -239,6 +288,14 @@ await RecordAsync( + $"{caps.CallsPerPeriod} (calls per period). Wait until fewer than {caps.CallsPerPeriod} calls fall {Within(caps.CallPeriod)}, then call again."); } + // A refusal here is a fault in the product, not in what a caller asked, so it is not recorded as a security event. + private static Result SystemRefused(SystemInitiator system, string action) + { + return Result.Failure( + AgentPermissionErrorCodes.SystemNotPermitted, + $"The '{system.Component}' engine cannot {action}: it makes only the moves it owns and never writes content (ADR-0012)."); + } + // The operation first, then the type, so the message names the first part of the scope that refuses. private static Result Allows(AgentCredential credential, AgentOperation operation, string contentType, string target) { @@ -345,6 +402,7 @@ private static string Describe(ChangeSetState state) { return state switch { + ChangeSetState.ChecksRunning => "checks running", ChangeSetState.ChecksFailed => "checks failed", ChangeSetState.AwaitingReview => "awaiting review", _ => state.ToString().ToLowerInvariant(), diff --git a/src/FluentCMS.Core/Agents/IAgentPermissions.cs b/src/FluentCMS.Core/Agents/IAgentPermissions.cs index 14b891cfa..be1a02831 100644 --- a/src/FluentCMS.Core/Agents/IAgentPermissions.cs +++ b/src/FluentCMS.Core/Agents/IAgentPermissions.cs @@ -5,7 +5,8 @@ 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 +// expired since its key was accepted, or one whose scope lacks the site, is refused. A system initiator makes only the moves its +// component owns and reads content, and is refused everything else (ADR-0012). 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 { diff --git a/src/FluentCMS.Core/Audit/AuditExportShape.cs b/src/FluentCMS.Core/Audit/AuditExportShape.cs index f8d295779..6f5244a10 100644 --- a/src/FluentCMS.Core/Audit/AuditExportShape.cs +++ b/src/FluentCMS.Core/Audit/AuditExportShape.cs @@ -44,7 +44,7 @@ internal static class AuditExportShape auditEvent.Operation, kind, Text(initiatorId), - auditEvent.Initiator.DisplayName, + InitiatorKinds.NameOf(auditEvent.Initiator), Text(auditEvent.AgentCredentialId), auditEvent.SubjectType, Text(auditEvent.SubjectId), diff --git a/src/FluentCMS.Core/Audit/InitiatorKinds.cs b/src/FluentCMS.Core/Audit/InitiatorKinds.cs index 71169f599..ba9dfef78 100644 --- a/src/FluentCMS.Core/Audit/InitiatorKinds.cs +++ b/src/FluentCMS.Core/Audit/InitiatorKinds.cs @@ -7,6 +7,7 @@ public static class InitiatorKinds { public const string User = "user"; public const string Agent = "agent"; + public const string System = "system"; public static (string Kind, Guid Id) Describe(Initiator initiator) { @@ -16,7 +17,29 @@ public static (string Kind, Guid Id) Describe(Initiator initiator) { UserInitiator user => (User, user.UserId), AgentInitiator agent => (Agent, agent.CredentialId), + SystemInitiator system => (System, system.Id), _ => throw new NotSupportedException($"The audit log cannot store an initiator of type {initiator.GetType().Name}."), }; } + + // The name stored with the kind and id: a system initiator's component, whatever its display name says. + public static string NameOf(Initiator initiator) + { + ArgumentNullException.ThrowIfNull(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. + 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); + } + } } diff --git a/src/FluentCMS.Core/ChangeSets/ChangeSet.cs b/src/FluentCMS.Core/ChangeSets/ChangeSet.cs index 7b4ff77dd..ad64d8332 100644 --- a/src/FluentCMS.Core/ChangeSets/ChangeSet.cs +++ b/src/FluentCMS.Core/ChangeSets/ChangeSet.cs @@ -61,6 +61,14 @@ public static Result Open(Guid siteId, string rationale, IEnumerable< errors.Add(new Error(ChangeSetErrorCodes.SiteMissing, "A change set belongs to a site, and the site id is empty.")); } + // A set always has a person or an agent behind it (CHG-01); the product's own engines move sets, never open them (ADR-0012). + if (initiator is SystemInitiator system) + { + errors.Add(new Error( + ChangeSetErrorCodes.InitiatorNotAllowed, + $"A change set is opened by a person or an agent, and '{system.Component}' is part of the product.")); + } + if (trimmedRationale.Length == 0) { errors.Add(new Error(ChangeSetErrorCodes.RationaleMissing, "A change set needs a rationale that says why the changes are made.")); diff --git a/src/FluentCMS.Core/ChangeSets/ChangeSetErrorCodes.cs b/src/FluentCMS.Core/ChangeSets/ChangeSetErrorCodes.cs index 2b1d8d7f1..3b8292c71 100644 --- a/src/FluentCMS.Core/ChangeSets/ChangeSetErrorCodes.cs +++ b/src/FluentCMS.Core/ChangeSets/ChangeSetErrorCodes.cs @@ -24,5 +24,6 @@ public static class ChangeSetErrorCodes public const string Conflict = "change_set_conflict"; public const string BaseVersionStale = "base_version_stale"; public const string RevertFixed = "revert_set_fixed"; + public const string InitiatorNotAllowed = "initiator_not_allowed"; public const string ItemKindMismatch = "item_kind_mismatch"; } diff --git a/src/FluentCMS.Core/Identity/Initiator.cs b/src/FluentCMS.Core/Identity/Initiator.cs index 42c592fb0..a2f35f81a 100644 --- a/src/FluentCMS.Core/Identity/Initiator.cs +++ b/src/FluentCMS.Core/Identity/Initiator.cs @@ -1,3 +1,7 @@ +using System.Security.Cryptography; +using System.Text; +using FluentCMS.Core.Schema; + namespace FluentCMS.Core.Identity; // Who caused a change: a person or an agent, never confusable in code or in the audit log (ADR-0009, AUD-01). @@ -6,3 +10,32 @@ public abstract record Initiator(string DisplayName); public sealed record UserInitiator(Guid UserId, string DisplayName) : Initiator(DisplayName); public sealed record AgentInitiator(Guid CredentialId, string DisplayName) : Initiator(DisplayName); + +// A part of the product acting on its own, with no person or agent behind it (ADR-0012), named for its component: the check engine +// moving a change set out of checks running, or a retention purge. It never opens a change set (CHG-01), and AgentPermissions lets it +// make only the moves its component owns and read content, never write it. +public sealed record SystemInitiator : Initiator +{ + public SystemInitiator(string component) + : base(component) + { + if (!SchemaNames.IsSlug(component)) + { + throw new ArgumentException($"'{component}' is not a component name; use lowercase words joined by hyphens, such as 'checks'.", nameof(component)); + } + + Component = component; + Id = new Guid(SHA256.HashData(Encoding.UTF8.GetBytes("fluentcms:system:" + component)).AsSpan(0, 16)); + } + + // The check engine, which moves a set from checks running to checks failed or awaiting review. + public static SystemInitiator Checks { get; } = new("checks"); + + // Maintenance that clears data past its retention, such as stored prompts (AUD-04). + public static SystemInitiator Retention { get; } = new("retention"); + + public string Component { get; } + + // The same for a component every time, so the audit log names one engine one way in every row. + public Guid Id { get; } +} diff --git a/src/FluentCMS.Core/Identity/UserErrorCodes.cs b/src/FluentCMS.Core/Identity/UserErrorCodes.cs index 3ac03ed0b..91e3f2471 100644 --- a/src/FluentCMS.Core/Identity/UserErrorCodes.cs +++ b/src/FluentCMS.Core/Identity/UserErrorCodes.cs @@ -7,4 +7,5 @@ public static class UserErrorCodes public const string CannotDisableSelf = "cannot_disable_self"; public const string UserDisabled = "user_disabled"; public const string ResetLinkInvalid = "reset_link_invalid"; + public const string PeopleOnly = "user_people_only"; } diff --git a/src/FluentCMS.Core/Jobs/IJobHandler.cs b/src/FluentCMS.Core/Jobs/IJobHandler.cs index 65aa2f5f4..025fc0f26 100644 --- a/src/FluentCMS.Core/Jobs/IJobHandler.cs +++ b/src/FluentCMS.Core/Jobs/IJobHandler.cs @@ -1,4 +1,5 @@ using FluentCMS.Core.Extensions; +using FluentCMS.Core.Identity; using FluentCMS.Core.Results; namespace FluentCMS.Core.Jobs; @@ -12,6 +13,10 @@ public interface IJobHandler // Stored in every job of the type, so it never changes once released. string Type { get; } + // The engine the type's jobs run as, whoever enqueued them, for work no person or agent does, such as the check run a submission + // starts (ADR-0012). The job still records who enqueued it. Null runs each job as whoever enqueued it. + SystemInitiator? RunAs => null; + Task RunAsync(JobContext context, CancellationToken cancellationToken); } diff --git a/src/FluentCMS.Host/Jobs/JobQueueWorker.cs b/src/FluentCMS.Host/Jobs/JobQueueWorker.cs index 554e8fefa..94f307fcb 100644 --- a/src/FluentCMS.Host/Jobs/JobQueueWorker.cs +++ b/src/FluentCMS.Host/Jobs/JobQueueWorker.cs @@ -62,7 +62,6 @@ private async Task RunNextAsync(string owner, JobQueueOptions settings, Ca } await using var runScope = scopes.CreateAsyncScope(); - runScope.ServiceProvider.GetRequiredService().Initiator = job.Initiator; var handler = runScope.ServiceProvider.GetServices().FirstOrDefault(candidate => candidate.Type == job.Type); if (handler is null) @@ -72,6 +71,8 @@ private async Task RunNextAsync(string owner, JobQueueOptions settings, Ca return true; } + runScope.ServiceProvider.GetRequiredService().Initiator = handler.RunAs ?? job.Initiator; + var context = new JobContext( job, runScope.ServiceProvider, diff --git a/src/FluentCMS.Infrastructure/Agents/AgentCredentialDirectory.cs b/src/FluentCMS.Infrastructure/Agents/AgentCredentialDirectory.cs index c776ef4d6..e53f0b054 100644 --- a/src/FluentCMS.Infrastructure/Agents/AgentCredentialDirectory.cs +++ b/src/FluentCMS.Infrastructure/Agents/AgentCredentialDirectory.cs @@ -137,10 +137,10 @@ public async Task> AuthenticateAsync(string key, Cancell return active.IsSuccess ? Result.Success(credential) : Result.Failure(active.Errors); } - // A credential grants permissions, which no agent may change, whatever its own scope (SEC-02). + // A credential grants permissions, which only a person changes: no agent, whatever its own scope (SEC-02), and no engine. private Result? RefuseAgent() { - return currentInitiator.Current is AgentInitiator + return currentInitiator.Current is AgentInitiator or SystemInitiator ? Result.Failure(AgentCredentialErrorCodes.PeopleOnly, "Only a person can issue or revoke an agent credential; an agent cannot.") : null; } diff --git a/src/FluentCMS.Infrastructure/Audit/AuditEventRow.cs b/src/FluentCMS.Infrastructure/Audit/AuditEventRow.cs index d81419541..b11003eb8 100644 --- a/src/FluentCMS.Infrastructure/Audit/AuditEventRow.cs +++ b/src/FluentCMS.Infrastructure/Audit/AuditEventRow.cs @@ -44,7 +44,7 @@ internal static AuditEventRow From(AuditEvent auditEvent) Timestamp = auditEvent.Timestamp, InitiatorKind = kind, InitiatorId = initiatorId, - InitiatorName = auditEvent.Initiator.DisplayName, + InitiatorName = InitiatorKinds.NameOf(auditEvent.Initiator), AgentCredentialId = auditEvent.AgentCredentialId, Operation = auditEvent.Operation, SubjectType = auditEvent.SubjectType, @@ -63,6 +63,7 @@ internal AuditEvent ToEvent() { InitiatorKinds.User => new UserInitiator(InitiatorId, InitiatorName), InitiatorKinds.Agent => new AgentInitiator(InitiatorId, InitiatorName), + InitiatorKinds.System => InitiatorKinds.RestoreSystem(InitiatorName, InitiatorId), _ => throw new InvalidOperationException($"Audit event {Id} names an unknown initiator kind '{InitiatorKind}'."), }; diff --git a/src/FluentCMS.Infrastructure/Checks/CheckDirectory.cs b/src/FluentCMS.Infrastructure/Checks/CheckDirectory.cs index f74a63ad3..4c17cf9e8 100644 --- a/src/FluentCMS.Infrastructure/Checks/CheckDirectory.cs +++ b/src/FluentCMS.Infrastructure/Checks/CheckDirectory.cs @@ -64,6 +64,11 @@ public async Task> CreateAsync(NewCheck check, CancellationToken c { ArgumentNullException.ThrowIfNull(check); + if (RefuseEngine("create") is { } refused) + { + return refused; + } + var created = Check.Create(SiteId, check.Name, check.Definition, checkTypes); if (!created.IsSuccess) { @@ -90,6 +95,11 @@ public async Task> ReviseAsync(string name, int baseVersion, Check ArgumentNullException.ThrowIfNull(name); ArgumentNullException.ThrowIfNull(definition); + if (RefuseEngine("revise") is { } refused) + { + return refused; + } + var row = await FindAsync(name, cancellationToken); if (row is null) { @@ -269,10 +279,18 @@ private async Task>> E : Result>.Failure(errors); } - // Activating decides what guards content, which no agent does, whatever its scope (D17, SEC-02). + // Activating decides what guards content, which only a person does (D17, SEC-02): no agent, whatever its scope, and no engine. + // A check says what guards content, which a person or an agent proposes and the product's engines only run (ADR-0012). + private Result? RefuseEngine(string action) + { + return currentInitiator.Current is SystemInitiator system + ? Result.Failure(CheckErrorCodes.PeopleOnly, $"The '{system.Component}' engine cannot {action} a check; a person or an agent does.") + : null; + } + private Result? RefuseAgent(string action) { - return currentInitiator.Current is AgentInitiator + return currentInitiator.Current is AgentInitiator or SystemInitiator ? Result.Failure(CheckErrorCodes.PeopleOnly, $"Only a person can {action} a check; an agent cannot.") : null; } diff --git a/src/FluentCMS.Infrastructure/Identity/UserDirectory.cs b/src/FluentCMS.Infrastructure/Identity/UserDirectory.cs index 2122baedb..cc8ecf9a6 100644 --- a/src/FluentCMS.Infrastructure/Identity/UserDirectory.cs +++ b/src/FluentCMS.Infrastructure/Identity/UserDirectory.cs @@ -38,6 +38,11 @@ public async Task> ListAsync(CancellationToken cancel public async Task> CreateAsync(NewUser newUser, CancellationToken cancellationToken = default) { + if (RefuseNonPerson("create") is { } refused) + { + return Result.Failure(refused.Errors); + } + await using var scope = await unitOfWork.BeginAsync(cancellationToken); var created = await Accounts.CreateAdministratorAsync(users, roles, clock, newUser); @@ -53,6 +58,11 @@ public async Task> CreateAsync(NewUser newUser, CancellationToken c public async Task DisableAsync(Guid userId, CancellationToken cancellationToken = default) { + if (RefuseNonPerson("disable") is { } refused) + { + return refused; + } + if (currentInitiator.Current is UserInitiator self && self.UserId == userId) { return Result.Failure(UserErrorCodes.CannotDisableSelf, "You cannot disable your own account."); @@ -78,6 +88,11 @@ public async Task DisableAsync(Guid userId, CancellationToken cancellati public async Task EnableAsync(Guid userId, CancellationToken cancellationToken = default) { + if (RefuseNonPerson("enable") is { } refused) + { + return refused; + } + return await ChangeAsync(userId, "user.enabled", async user => { var result = await users.SetLockoutEndDateAsync(user, null); @@ -114,4 +129,12 @@ private async Task ChangeAsync( await scope.CommitAsync(cancellationToken); return Result.Success(); } + + // Who may sign in is a person's decision: no agent, whatever its scope (SEC-02), and no engine (ADR-0012). + private Result? RefuseNonPerson(string action) + { + return currentInitiator.Current is AgentInitiator or SystemInitiator + ? Result.Failure(UserErrorCodes.PeopleOnly, $"Only a person can {action} a user; {currentInitiator.Current.DisplayName} cannot.") + : null; + } } diff --git a/src/FluentCMS.Infrastructure/Jobs/JobQueue.cs b/src/FluentCMS.Infrastructure/Jobs/JobQueue.cs index 412eb3587..dbe99e63a 100644 --- a/src/FluentCMS.Infrastructure/Jobs/JobQueue.cs +++ b/src/FluentCMS.Infrastructure/Jobs/JobQueue.cs @@ -40,7 +40,7 @@ public Task EnqueueAsync(NewJob job, CancellationToken cancellationToken = SubjectId = job.Subject?.Id, InitiatorKind = described?.Kind, InitiatorId = described?.Id, - InitiatorName = initiator?.DisplayName, + InitiatorName = initiator is null ? null : InitiatorKinds.NameOf(initiator), State = JobState.Queued, MaxAttempts = job.MaxAttempts, CreatedAt = now, diff --git a/src/FluentCMS.Infrastructure/Jobs/JobRow.cs b/src/FluentCMS.Infrastructure/Jobs/JobRow.cs index e78e7d77f..ed9aaff82 100644 --- a/src/FluentCMS.Infrastructure/Jobs/JobRow.cs +++ b/src/FluentCMS.Infrastructure/Jobs/JobRow.cs @@ -67,6 +67,7 @@ internal Job ToJob() { InitiatorKinds.User => new UserInitiator(InitiatorId!.Value, InitiatorName!), InitiatorKinds.Agent => new AgentInitiator(InitiatorId!.Value, InitiatorName!), + InitiatorKinds.System => InitiatorKinds.RestoreSystem(InitiatorName!, InitiatorId!.Value), null => null, _ => throw new InvalidOperationException($"Job {Id} names an unknown initiator kind '{InitiatorKind}'."), }, diff --git a/src/FluentCMS.Infrastructure/Models/ModelCallLog.cs b/src/FluentCMS.Infrastructure/Models/ModelCallLog.cs index c1b4843f8..8796d1301 100644 --- a/src/FluentCMS.Infrastructure/Models/ModelCallLog.cs +++ b/src/FluentCMS.Infrastructure/Models/ModelCallLog.cs @@ -86,10 +86,11 @@ public async Task> FindByPromptHashAsync(string promptH public async Task> PurgeAsync(CancellationToken cancellationToken = default) { - // Purging removes what the audit log points at, which no agent decides (AUD-N2). - if (currentInitiator.Current is not UserInitiator) + // Purging removes what the audit log points at, which no agent decides (AUD-N2): a person does, or the retention engine. + var initiator = currentInitiator.Current; + if (initiator is not UserInitiator && !(initiator is SystemInitiator system && system.Component == SystemInitiator.Retention.Component)) { - return Result.Failure(ModelErrorCodes.PeopleOnly, "Only a signed-in person can purge stored prompts; an agent cannot."); + return Result.Failure(ModelErrorCodes.PeopleOnly, "Only a person, or the retention purge, can purge stored prompts; an agent cannot."); } var now = clock.GetUtcNow(); diff --git a/tests/FluentCMS.IntegrationTests/Identity/SystemInitiatorTests.cs b/tests/FluentCMS.IntegrationTests/Identity/SystemInitiatorTests.cs new file mode 100644 index 000000000..5f36aa095 --- /dev/null +++ b/tests/FluentCMS.IntegrationTests/Identity/SystemInitiatorTests.cs @@ -0,0 +1,192 @@ +using System.Collections.Concurrent; +using System.Text.Json; +using System.Text.Json.Nodes; +using FluentCMS.Core.Agents; +using FluentCMS.Core.Audit; +using FluentCMS.Core.Checks; +using FluentCMS.Core.Extensions; +using FluentCMS.Core.Identity; +using FluentCMS.Core.Jobs; +using FluentCMS.Core.Models; +using FluentCMS.Core.Persistence; +using FluentCMS.Core.Results; +using FluentCMS.Core.Sites; +using FluentCMS.Infrastructure.Persistence; +using FluentCMS.IntegrationTests.Audit; +using FluentCMS.IntegrationTests.Checks; +using FluentCMS.IntegrationTests.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace FluentCMS.IntegrationTests.Identity; + +// ADR-0012: what the product does on its own is recorded as the system, never as a person or an agent. +public sealed class SystemInitiatorTests +{ + private static readonly AuditSubject Set = new("change_set", Guid.NewGuid()); + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task An_engine_s_event_reads_back_and_exports_as_the_system_with_no_credential(string provider) + { + await using var scenario = await AuditScenario.CreateAsync(provider); + var at = new DateTimeOffset(2026, 10, 19, 9, 0, 0, TimeSpan.Zero); + + await scenario.AppendAsync(at, new NewAuditEvent("change_set.checks_passed") { SubjectType = Set.Type, SubjectId = Set.Id }, SystemInitiator.Checks); + + var read = Assert.Single(await scenario.ReadAsync(new AuditQuery { Subject = Set })); + var exported = JsonNode.Parse(await scenario.ExportAsync(new AuditQuery { Subject = Set }, AuditExportFormat.Json))!.AsArray(); + var row = await QueryAsync(scenario, context => context.AuditEvents.AsNoTracking().SingleAsync(e => e.Operation == "change_set.checks_passed")); + + var system = Assert.IsType(read.Initiator); + Assert.Equal(("checks", SystemInitiator.Checks.Id), (system.Component, system.Id)); + Assert.Null(read.AgentCredentialId); + Assert.Equal(("system", SystemInitiator.Checks.Id, "checks", (Guid?)null), (row.InitiatorKind, row.InitiatorId, row.InitiatorName, row.AgentCredentialId)); + var exportedEvent = exported.Single(e => (string?)e!["operation"] == "change_set.checks_passed")!; + Assert.Equal( + ("system", SystemInitiator.Checks.Id.ToString(), "checks", (string?)null), + ((string?)exportedEvent["initiatorKind"], (string?)exportedEvent["initiatorId"], (string?)exportedEvent["initiatorName"], (string?)exportedEvent["agentCredentialId"])); + } + + // Only a person issues credentials and activates checks; an engine is refused, as an agent is. + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task An_engine_cannot_write_or_activate_a_check_issue_a_credential_or_manage_users(string provider) + { + await using var scenario = await CheckScenario.CreateAsync(provider); + await scenario.CreateAsync("no-superlatives", CheckScenario.NoSuperlatives()); + scenario.Initiator = SystemInitiator.Checks; + + var create = await scenario.CreateAsync("more-checks", CheckScenario.NoSuperlatives()); + var revise = await scenario.RunAsync(directory => directory.ReviseAsync("no-superlatives", 1, CheckScenario.NoSuperlatives("finest"))); + var activate = await scenario.RunAsync(directory => directory.ActivateAsync("no-superlatives", 1)); + await using var users = scenario.Services.CreateAsyncScope(); + var user = await users.ServiceProvider.GetRequiredService().CreateAsync(new NewUser("engine@example.com", "Engine", "a-long-enough-password-1")); + await using var scope = scenario.Services.CreateAsyncScope(); + var issue = await scope.ServiceProvider.GetRequiredService().IssueAsync(new NewAgentCredential( + "engine-made", + DateTimeOffset.UtcNow.AddDays(30), + AgentScope.Create([Site.Default.Id], null, null, [AgentOperation.Read]).Value, + AgentCaps.Create(10, TimeSpan.FromHours(1), 1m, TimeSpan.FromDays(1), 10, 10).Value)); + + Assert.Equal(CheckErrorCodes.PeopleOnly, Assert.Single(create.Errors).Code); + Assert.Equal(CheckErrorCodes.PeopleOnly, Assert.Single(revise.Errors).Code); + Assert.Equal(CheckErrorCodes.PeopleOnly, Assert.Single(activate.Errors).Code); + Assert.Equal(AgentCredentialErrorCodes.PeopleOnly, Assert.Single(issue.Errors).Code); + Assert.Equal(UserErrorCodes.PeopleOnly, Assert.Single(user.Errors).Code); + } + + // AUD-04: the retention purge runs as the retention engine, and no other engine may purge. + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task The_retention_engine_purges_stored_prompts_and_logs_each_as_itself(string provider) + { + await using var scenario = await ModelScenario.CreateAsync(provider, new() { ["Models:PromptRetention"] = "1.00:00:00" }); + var old = (await scenario.AskAsync(ModelScenario.Tone)).Value; + scenario.Clock.Now = scenario.Clock.Now.AddDays(2); + + scenario.Initiator = SystemInitiator.Checks; + var byChecks = await scenario.RunAsync(services => services.GetRequiredService().PurgeAsync()); + scenario.Initiator = SystemInitiator.Retention; + var byRetention = await scenario.RunAsync(services => services.GetRequiredService().PurgeAsync()); + + Assert.Equal(ModelErrorCodes.PeopleOnly, Assert.Single(byChecks.Errors).Code); + Assert.Equal(1, byRetention.Value); + var purge = Assert.Single(await scenario.PurgeEventsAsync()); + Assert.Equal((old.CallId, "system", "retention"), (purge.SubjectId!.Value, purge.InitiatorKind, purge.InitiatorName)); + } + + // A job an engine enqueues, such as the retention purge scheduling its next run, records that engine. + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task A_job_an_engine_enqueues_is_stored_as_that_engine(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(); + } + + 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. + [Fact] + public async Task The_worker_runs_a_job_as_the_engine_its_handler_names() + { + using var install = new TemporaryInstall(); + await using var host = await AdminHost.StartAsync( + install, + settings: new Dictionary { ["JobQueue:PollingInterval"] = "00:00:00.050" }, + configure: builder => builder.Services.AddFluentCmsExtension()); + var administrator = await host.CreateFirstAdministratorAsync(); + + var job = await host.AsUserAsync(administrator, async services => + { + 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; + }); + + var deadline = DateTime.UtcNow.AddSeconds(30); + while (!EngineHandler.RanAs.ContainsKey(job.Id) && DateTime.UtcNow < deadline) + { + await Task.Delay(50); + } + + Assert.Equal(SystemInitiator.Checks, EngineHandler.RanAs[job.Id]); + Assert.IsType(job.Initiator); + } + + // A component's name and id are stored together, and a row whose id is not its component's is refused as a fault. + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task An_engine_is_stored_by_its_component_whatever_its_display_name(string provider) + { + await using var scenario = await AuditScenario.CreateAsync(provider); + var renamed = SystemInitiator.Checks with { DisplayName = "The check engine" }; + + await scenario.AppendAsync(DateTimeOffset.UnixEpoch.AddDays(1), new NewAuditEvent("checks.renamed") { SubjectType = Set.Type, SubjectId = Set.Id }, renamed); + + var read = Assert.Single(await scenario.ReadAsync(new AuditQuery { Subject = Set })); + 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())); + } + + private static async Task QueryAsync(AuditScenario scenario, Func> query) + { + await using var scope = scenario.CreateScope(); + return await query(scope.ServiceProvider.GetRequiredService()); + } + + private sealed class EngineHandler : IJobHandler + { + public const string Name = "test.engine"; + + public static ConcurrentDictionary RanAs { get; } = new(); + + public string Type => Name; + + public SystemInitiator RunAs => SystemInitiator.Checks; + + public Task RunAsync(JobContext context, CancellationToken cancellationToken) + { + RanAs[context.Job.Id] = context.Services.GetRequiredService().Current; + return Task.FromResult(Result.Success()); + } + } +} diff --git a/tests/FluentCMS.Tests/Identity/SystemInitiatorTests.cs b/tests/FluentCMS.Tests/Identity/SystemInitiatorTests.cs new file mode 100644 index 000000000..b61b13b20 --- /dev/null +++ b/tests/FluentCMS.Tests/Identity/SystemInitiatorTests.cs @@ -0,0 +1,245 @@ +using FluentCMS.Core; +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 Microsoft.Extensions.DependencyInjection; + +namespace FluentCMS.Tests.Identity; + +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.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")); + } + + // CHG-01: a change set always has a person or an agent behind it. + [Fact] + public void A_change_set_cannot_be_opened_by_a_part_of_the_product() + { + var opened = ChangeSet.Open(Site.Default.Id, "Tidy up.", [], SystemInitiator.Checks); + + var error = Assert.Single(opened.Errors); + Assert.Equal(ChangeSetErrorCodes.InitiatorNotAllowed, error.Code); + Assert.Equal("A change set is opened by a person or an agent, and 'checks' is part of the product.", error.Message); + } + + [Fact] + public void A_published_set_cannot_be_reverted_by_a_part_of_the_product() + { + var published = Set(ChangeSetState.Published, new UserInitiator(Guid.NewGuid(), "Amir")); + + Assert.Equal(ChangeSetErrorCodes.InitiatorNotAllowed, Assert.Single(published.Revert("Undo.", SystemInitiator.Checks).Errors).Code); + } + + internal static ChangeSet Set(ChangeSetState state, Initiator initiator) + { + var operation = ChangeOperation.Create(OperationKind.Create, ItemKind.Schema, Guid.NewGuid(), null, 1).Value; + return ChangeSet.Restore(Guid.NewGuid(), Site.Default.Id, "Launch the kettle.", [], initiator, null, [operation], state, null); + } +} + +// ADR-0012: the check engine makes the moves it owns and reads content; everything else is refused, and agents still cannot make +// the engine's moves. +public sealed class SystemPermissionTests : IDisposable +{ + private static readonly AgentInitiator Agent = new(Guid.NewGuid(), "example-agent"); + + private readonly SwitchableInitiator _initiator = new(); + private readonly RecordingAuditLog _audit = new(); + private readonly ServiceProvider _provider; + + public SystemPermissionTests() + { + var caps = AgentCaps.Create(100, TimeSpan.FromHours(1), 5m, TimeSpan.FromDays(1), 50, 10).Value; + var credential = AgentCredential.Restore( + Agent.CredentialId, + Agent.DisplayName, + AgentScope.Restore([Site.Default.Id], null, null, [AgentOperation.Read, AgentOperation.Create, AgentOperation.Update]), + caps, + DateTimeOffset.UtcNow.AddDays(-1), + DateTimeOffset.UtcNow.AddDays(30), + null); + + _provider = new ServiceCollection() + .AddFluentCmsCore() + .AddSingleton(_initiator) + .AddSingleton(new OneCredential(credential)) + .AddSingleton(new NoActivity()) + .AddSingleton(new NoUnitOfWork()) + .AddSingleton(_audit) + .AddSingleton(TimeProvider.System) + .BuildServiceProvider(); + } + + private IAgentPermissions Permissions => _provider.GetRequiredService(); + + [Theory] + [InlineData(ChangeSetState.ChecksRunning, ChangeSetState.ChecksFailed, true)] + [InlineData(ChangeSetState.ChecksRunning, ChangeSetState.AwaitingReview, true)] + [InlineData(ChangeSetState.ChecksRunning, ChangeSetState.Approved, false)] + [InlineData(ChangeSetState.Open, ChangeSetState.ChecksRunning, false)] + [InlineData(ChangeSetState.ChecksFailed, ChangeSetState.Open, false)] + [InlineData(ChangeSetState.AwaitingReview, ChangeSetState.Approved, false)] + [InlineData(ChangeSetState.Approved, ChangeSetState.Published, false)] + [InlineData(ChangeSetState.ChecksFailed, ChangeSetState.Rejected, false)] + [InlineData(ChangeSetState.Published, ChangeSetState.Reverted, false)] + public async Task The_check_engine_makes_only_the_moves_it_owns(ChangeSetState from, ChangeSetState to, bool allowed) + { + _initiator.Current = SystemInitiator.Checks; + + var result = await Permissions.CheckTransitionAsync(SystemInitiatorTests.Set(from, Agent), to); + + Assert.Equal(allowed, result.IsSuccess); + if (!allowed) + { + Assert.Equal(AgentPermissionErrorCodes.SystemNotPermitted, Assert.Single(result.Errors).Code); + } + } + + [Fact] + public async Task Another_engine_owns_none_of_the_checks_moves() + { + _initiator.Current = SystemInitiator.Retention; + + var set = SystemInitiatorTests.Set(ChangeSetState.ChecksRunning, Agent); + + var result = await Permissions.CheckTransitionAsync(set, ChangeSetState.AwaitingReview); + + Assert.Equal( + $"The 'retention' engine cannot move change set {set.Id} from checks running to awaiting review: " + + "it makes only the moves it owns and never writes content (ADR-0012).", + Assert.Single(result.Errors).Message); + } + + [Fact] + public async Task An_engine_reads_content_and_never_writes_it_opens_or_adds_to_a_set_or_calls_a_tool() + { + _initiator.Current = SystemInitiator.Checks; + var set = SystemInitiatorTests.Set(ChangeSetState.Open, Agent); + + Assert.True((await Permissions.CheckContentAsync("product-page", AgentOperation.Read)).IsSuccess); + Assert.True((await Permissions.CheckDefinitionAsync("product-page", AgentOperation.Read)).IsSuccess); + Assert.True((await Permissions.ReadableTypesAsync())("product-page")); + + Result[] refused = + [ + await Permissions.CheckContentAsync("product-page", AgentOperation.Create), + await Permissions.CheckContentAsync("product-page", AgentOperation.Update, ["headline"]), + await Permissions.CheckContentAsync("product-page", AgentOperation.Delete), + await Permissions.CheckDefinitionAsync("product-page", AgentOperation.Update), + await Permissions.CheckNewChangeSetAsync(), + await Permissions.CheckChangeSetSizeAsync(set, 1), + await Permissions.CheckToolCallAsync(), + ]; + + Assert.All(refused, result => Assert.Equal(AgentPermissionErrorCodes.SystemNotPermitted, Assert.Single(result.Errors).Code)); + Assert.Empty(_audit.Events); + } + + // The engine's moves stay out of an agent's reach: a set it submitted is moved on by the checks, not by the agent. + [Theory] + [InlineData(ChangeSetState.ChecksFailed)] + [InlineData(ChangeSetState.AwaitingReview)] + public async Task An_agent_still_cannot_make_the_checks_moves_on_its_own_set(ChangeSetState target) + { + _initiator.Current = Agent; + + var result = await Permissions.CheckTransitionAsync(SystemInitiatorTests.Set(ChangeSetState.ChecksRunning, Agent), target); + + Assert.Equal(AgentPermissionErrorCodes.TransitionNotPermitted, Assert.Single(result.Errors).Code); + Assert.Equal("agent.transition_refused", Assert.Single(_audit.Events).Operation); + } + + public void Dispose() + { + _provider.Dispose(); + } + + private sealed class SwitchableInitiator : ICurrentInitiator + { + public Initiator? Current { get; set; } + } + + private sealed class RecordingAuditLog : IAuditLog + { + public List Events { get; } = []; + + public Task AppendAsync(NewAuditEvent entry, CancellationToken cancellationToken = default) + { + Events.Add(entry); + return Task.FromResult(new AuditEvent + { + Id = Guid.NewGuid(), + Timestamp = DateTimeOffset.UtcNow, + Initiator = Agent, + Operation = entry.Operation, + }); + } + } + + private sealed class NoUnitOfWork : IUnitOfWork + { + public Task BeginAsync(CancellationToken cancellationToken = default) + { + return Task.FromResult(new Scope()); + } + + private sealed class Scope : IUnitOfWorkScope + { + public Task CommitAsync(CancellationToken cancellationToken = default) + { + return Task.CompletedTask; + } + + public ValueTask DisposeAsync() + { + return ValueTask.CompletedTask; + } + } + } + + private sealed class NoActivity : IAgentActivity + { + public Task ChangeSetsOpenedSinceAsync(Guid credentialId, DateTimeOffset since, CancellationToken cancellationToken = default) + { + return Task.FromResult(0); + } + + public Task ToolCallsSinceAsync(Guid credentialId, DateTimeOffset since, CancellationToken cancellationToken = default) + { + return Task.FromResult(0); + } + } + + private sealed class OneCredential(AgentCredential credential) : IAgentCredentialDirectory + { + public Task> ListAsync(CancellationToken cancellationToken = default) + { + return Task.FromResult>([credential]); + } + + public Task> GetAsync(Guid id, CancellationToken cancellationToken = default) + { + return Task.FromResult(id == credential.Id + ? Result.Success(credential) + : Result.Failure("credential_not_found", "No such credential.")); + } + + public Task> IssueAsync(NewAgentCredential issued, CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + public Task> RevokeAsync(Guid id, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + + public Task> AuthenticateAsync(string key, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + } +}