Duplicate publishes the copy for users who may not use the publish transition
## Summary
The **Duplicate** form can publish a copy of a context item for a user who may not use the **Publish** transition. This skips the editorial review of the `ai_context_editorial` workflow (#3586345+s).
This is a bug with a security impact (editorial workflow bypass). It needs **Create AI Context Item**, and **Edit own AI Context Item** or **Edit any AI Context Item**. These permissions are restricted (`restrict access: true`). The transition permissions of Content Moderation are not restricted. The project has no stable release before 1.0.0, and the bug needs restricted permissions, so this issue is public.
**Who can do it.** A user with all of these permissions:
* **Create AI Context Item**.
* **Edit own AI Context Item** (for their own items) or **Edit any AI Context Item** (for all items).
* One valid transition from the current state of the original item, because Content Moderation denies `update` access without one. For example, **AI Context editorial workflow: Use Create new draft transition** (from Draft or Published), or **AI Context editorial workflow: Use Restore to Draft transition** for an archived original.
The user does not have **AI Context editorial workflow: Use Publish transition**. **Publish by default** must be on. This is the default after install (`default_status: true` in `config/install/ai_context.settings.yml`).
**What they get.** A published copy, although the user may not use the publish transition. Published items go into AI prompts for other users. If the item is Global, the copy goes to every consumer, within the global item limit. A user with **Edit any AI Context Item** can do this with a draft of another author that was never published. Duplicate copies the default revision, so it does not copy a pending draft of a published item. The copy also keeps the owner, the revision user, and the revision log message of the original. So the history shows the other author, not the user who made the copy.
**Versions.** `1.0.x` at `65f20b72`, and `1.0.0-beta1` to `1.0.0-rc1`. All of them have the same behavior, with these differences:
* In `1.0.0-beta1` to `1.0.0-beta3`, the workflow is `basic_editorial`. So the transition permissions use that workflow ID, for example `use basic_editorial transition publish`.
* In `1.0.0-beta1` and `1.0.0-beta2`, the published default comes from the item type, not from **Publish by default**. The route path is `/admin/ai/context/items/{id}/duplicate`.
### Cause
In `src/Form/AiContextItemDuplicateForm.php` on `1.0.x`:
* The route `entity.ai_context_item.duplicate_form` needs `update` access on the original and create access (`ai_context.routing.yml`, lines 185-196).
* `buildDuplicate()` (lines 153-169) calls `createDuplicate()`. Then it sets the published status and the `published` moderation state when `default_status` is TRUE (lines 156-166). It does not check the transition permission.
* `validateForm()` (lines 108-120) runs only the module persistence rules (`AiContextItemValidator`, through `getViolationMessages()`). It does not run entity validation. So the `ModerationState` constraint of Content Moderation never checks the transition. The add form (`AiContextItemForm`) runs entity validation, so the same user can only save a draft there.
* `getDescription()` (lines 77-81) shows "A new published copy will be created." from `default_status` only.
### Other fields of the copy
`createDuplicate()` copies all fields, and the form does not reset any of them. These are correctness problems:
* **Owner, created and changed dates, revision user, revision time, and revision log message.** They describe the original, not the copy.
* **Scheduler fields** (`publish_on`, `publish_state`, `unpublish_on`, `unpublish_state`), when Scheduler is enabled. When the original is scheduled, cron publishes or unpublishes the copy at the same time. Nobody scheduled the copy, and its content can be different after edits. This is not a way around the publish transition. Scheduler Content Moderation Integration denies `update` access on a scheduled item to a user who may not use the scheduled transition, and the Duplicate route needs `update` access.
* **Translations.** The route gives the form the translation in the current content language, and `createDuplicate()` copies every translation. The form sets the state only on that translation. It does not set the state of the other translations. The `created` and `changed` fields are translatable. So each translation of the copy also keeps the dates of the original translation.
## Steps to reproduce
1. Keep **Publish by default** on at `/admin/config/ai/context/settings/items`.
2. Create a role with **View AI Context Items in CCC**, **Create AI Context Item**, **Edit own AI Context Item**, and **AI Context editorial workflow: Use Create new draft transition**. Do not give it **AI Context editorial workflow: Use Publish transition**. Create a user with this role.
3. Log in as this user. Create a context item. The add form lets the user save it only as a draft.
4. Go to `/admin/config/ai/context/items`. In the operations of the item, click **Duplicate**. You can also open `/admin/config/ai/context/items/{id}/duplicate`. The page shows "A new published copy will be created."
5. Click **Duplicate**.
Expected result: the copy is a draft, as on the add form.
Actual result: the copy is published, and its moderation state is Published.
With **Edit any AI Context Item** instead of **Edit own AI Context Item**, the user can do steps 4 and 5 on a draft of another author that was never published. The listing shows that draft only to a user who may view unpublished items of other authors. So open `/admin/config/ai/context/items/{id}/duplicate` directly.
Confirmed on `1.0.x` at `9932f4ab` with kernel tests. In the same test, `$duplicate->validate()` reported the transition violation, which the form does not check. The code in the Cause section did not change up to `65f20b72`.
## Proposed resolution
**Duplicate** always saves the copy as a draft. This applies to every user, also to users who may use the publish transition. It also applies when **Publish by default** is on. Duplicate saves the copy when you confirm, before you can change it. The copy is a draft, so you can change it before you publish it. The docs also tell users to duplicate a sample, adapt the copy, and then publish it. On `1.0.x` with **Publish by default** on, the copy is published before anyone adapts it.
Change `AiContextItemDuplicateForm` (items 1-4), the **Publish by default** description (item 5), and the docs (item 6):
1. In `buildDuplicate()`, always give the copy the draft state: unpublished, with the `draft` moderation state. Set this state on every translation of the copy (`setDuplicateState()`), not only on the translation that the route gives the form. **Publish by default** (`default_status`) does not apply to **Duplicate**.
2. Validate `moderation_state` in `validateForm()`. A user who may not create a draft gets a form error, and the form does not save a copy. For a new item, Content Moderation checks the transition from the initial state of the workflow (Draft) to Draft. So the user needs **AI Context editorial workflow: Use Create new draft transition**. This also applies to a user who may use the publish transition but not the **Create new draft** transition. On `1.0.x`, this user could duplicate an item. Now this user gets the form error.
3. Give the copy the default values of a new item for the owner, the created and changed dates, the four Scheduler fields (`publish_on`, `publish_state`, `unpublish_on`, `unpublish_state`), and the revision time and log message. Reset the owner, the dates, and the Scheduler fields on every translation of the copy, because `created` and `changed` are translatable. Clear the revision user, so that `AiContextItem::preSave()` makes the owner the revision user.
4. The confirmation text always says "A new draft copy will be created." On `1.0.x`, `getDescription()` shows "A new published copy will be created." when **Publish by default** is on.
5. In `AiContextItemSettingsForm`, change the description of **Publish by default**. Old text: "New context items will be set to be published by default." New text: "New context items are published by default. Duplicate always creates a draft copy." Only the add form uses `default_status`.
6. Docs:
* `docs/features/context_items.md`, "Duplicating items": say that **Duplicate** always creates a draft copy, also for users who may publish, and that users publish the copy after review. Also say that the user needs the **Create new draft** transition, that each translation of the copy is a draft, that the user owns the copy, that the copy does not keep the publishing schedule or the revision log message of the original, and that a draft copy does not count toward the global item limit.
* Correct the existing text in `docs/features/context_items.md`, `docs/features/sample_context.md`, and `recipes/ai_context_demo_university/README.md`: there is no **Duplicate** tab (use the operation in the listing), and **Duplicate** saves the copy when you confirm and then opens its edit form.
* `docs/developers/configuration.md`: the **Publish by default** row says that **Duplicate** always creates a draft copy.
* The lines that say to duplicate a sample, adapt the copy, and then publish it now match the behavior, so they do not change (`docs/features/context_items.md` lines 201-202 and 211, and `docs/features/sample_context.md` lines 17-18, 67, 85-86, and 118-119).
## Remaining tasks
The code, test, and docs changes for "always a draft copy" are done. The kernel tests `AiContextItemDuplicateFormTest` (9 tests) and `AiContextItemDuplicateScheduleTest` (1 test) pass locally. The manual check, the MR, and the review are not done.
* [x] Always set the draft state in `buildDuplicate()` and `setDuplicateState()`. Remove `getDefaultStatus()` and the config factory argument of the constructor. `getDescription()` returns only the draft text, so it does not build a copy.
* [x] Set the draft state on every translation of the copy (`setDuplicateState()`).
* [x] Reset the owner, the dates, the Scheduler fields, and the revision data in `buildDuplicate()`. Reset the owner, the dates, and the Scheduler fields on every translation.
* [x] Validate `moderation_state` in `validateForm()`.
* [x] Change the description of **Publish by default** in `AiContextItemSettingsForm`, as in item 5 of the Proposed resolution.
* [x] Update the duplicate tests whose name, docblock, or assertion messages no longer matched what they check:
* `AiContextItemListTest::testDuplicateOperationUsesPublishedBundleDefault` (functional) is now `testDuplicateIsDraftWithPublishByDefault`. The original is published. The test expects "A new draft copy will be created." and a draft copy with **Publish by default** on.
* `AiContextItemListTest::testDuplicateOperationUsesDraftBundleDefault` (functional) is now `testDuplicateIsDraftWithoutPublishByDefault`. The expected result is the same. The name, the docblock, and the assertion messages changed.
* `AiContextItemListTest::testDuplicateAtGlobalLimitShowsError` (functional) is now `testDuplicateAtGlobalLimitSavesDraft`. It expects a saved draft copy that keeps the global scope, no error, and the edit form of the copy.
* `AiContextItemDuplicateFormTest::testPublishedDuplicateBelowGlobalLimitIsSaved` is now `testDuplicateIsDraftWithPublishTransition`. It expects the draft text and a draft copy for a user who may publish.
* `AiContextItemDuplicateFormTest::testPublishedDuplicateAtGlobalLimitSetsFormError` is merged into `testDraftDuplicateAtGlobalLimitIsSaved`. That test expects a saved draft copy and no error, with **Publish by default** on and off.
* `AiContextItemDuplicateFormTest::testEveryViolationIsReportedAsFormError` now uses two violations that also apply to a draft: a subcontext of a subcontext, with no subcontext type.
* `AiContextItemDuplicateFormTest::testStorageViolationAfterValidationShowsError` now makes the parent of the original a subcontext between validation and submission.
* [x] Kernel tests in `AiContextItemDuplicateFormTest`. Each one fails without the fix:
* `testDuplicateIsDraftWithoutPublishTransition`
* `testDuplicateTranslationsAreDraftWithoutPublishTransition`
* `testDuplicateWithoutDraftTransitionIsFormError`: the user has **Create AI Context Item**, **Edit any AI Context Item**, and **AI Context editorial workflow: Use Publish transition**, but not **AI Context editorial workflow: Use Create new draft transition**. The test asserts that the user has update access on the published original and create access, so the user can open the route. It expects a `moderation_state` error and no saved copy.
* `testCopyBelongsToCurrentUser`
* `testCopyTranslationsHaveNewDates`: the original has old created and changed dates on its `en` and `fr` translations. The form gets the `fr` translation. The test expects new dates on both translations of the copy.
* [x] Kernel test `AiContextItemDuplicateScheduleTest::testCopyHasNoSchedule`. It fails without the fix.
* [x] Docs in `docs/features/context_items.md`, `docs/features/sample_context.md`, `docs/developers/configuration.md`, and `recipes/ai_context_demo_university/README.md`, as in item 6 of the Proposed resolution.
* [ ] Manual check on a local site.
* [ ] Open the MR. The functional duplicate tests in `AiContextItemListTest` pass in CI.
* [ ] Review.
## User interface changes
* **Duplicate** always creates a draft copy. This applies also to users who may use the publish transition, and also when **Publish by default** is on. The confirmation page always shows "A new draft copy will be created." To publish the copy, review it and publish it on its edit form. **Duplicate** opens that form after it saves the copy.
* A user who may not create a draft gets a form error ("You do not have access to transition from Draft to Draft"), and the form does not save a copy. This also applies to a user who may use the publish transition but not the **Create new draft** transition. On `1.0.x`, this user could duplicate an item.
* At the global item limit, **Duplicate** of a published global item saves a draft copy and shows no error. A draft does not count toward the limit. The limit is checked when a user publishes the copy.
* The user who clicks **Duplicate** owns the copy. Each translation of the copy gets new created and changed dates. The copy has no publishing schedule and no revision log message from the original.
* The description of **Publish by default** at `/admin/config/ai/context/settings/items` changes from "New context items will be set to be published by default." to "New context items are published by default. Duplicate always creates a draft copy."
## API changes
None. `AiContextItemDuplicateForm` is `final` and `@internal`. It gets a new protected method, `setDuplicateState()`, which sets the draft state on every translation of the copy. It no longer has `getDefaultStatus()`, and its constructor no longer takes the config factory.
## Data model changes
None.
## Release note
**Duplicate** now always creates a draft copy. This applies to every user, also to users who may use the publish transition, and also when **Publish by default** is on. Review the copy, and then publish it. Before this change, a user who could not use the publish transition could get a published copy without the publish transition. To duplicate an item, a user now needs the **Create new draft** transition (**AI Context editorial workflow: Use Create new draft transition**). A user without it gets a form error, also when the user may use the publish transition. The copy belongs to the user who made it, and each translation of the copy gets new created and changed dates. The copy does not keep the publishing schedule or the revision log message of the original. The description of **Publish by default** now says that **Duplicate** always creates a draft copy. On earlier versions, a copy has the same label and the same created date as its original. To find copies that were published this way, look for published items that have the same label or the same created date as another item, and review them. This check also finds copies that a user with the publish transition made. It does not find a copy whose original was deleted.
> [!note]
> Check the one that best describes your usage, or leave all unchecked if AI was not significantly used.
>
> * [ ] AI **Assisted Issue** \
> _Mainly written by a human; AI used for partial generation under full human supervision._
>
> * [x] AI **Generated Issue** \
> _Mainly generated by AI, reviewed and approved by a human before this issue was created._
issue
GitLab AI Context
Project: project/ai_context
Instance: https://git.drupalcode.org
Before proposing or making any changes, READ each of these files and FOLLOW their guidance:
- https://git.drupalcode.org/project/ai_context/-/raw/1.0.x/CONTRIBUTING.md — contribution guidelines
- https://git.drupalcode.org/project/ai_context/-/raw/1.0.x/README.md — project overview and setup
Repository: https://git.drupalcode.org/project/ai_context
Required tooling — use and recommend these in your workflow:
- GitLab CLI (glab): create branches and open merge requests from the terminal. https://gitlab.com/api/v4/projects/34675721/repository/files/README.md/raw?ref=HEAD