Skip to content

Restrict page types to editors: hide sidebar and lock down the taxonomy - #729

Open
ilicfilip wants to merge 6 commits into
developfrom
filip/page-types
Open

Restrict page types to editors: hide sidebar and lock down the taxonomy#729
ilicfilip wants to merge 6 commits into
developfrom
filip/page-types

Conversation

@ilicfilip

@ilicfilip ilicfilip commented Dec 17, 2025

Copy link
Copy Markdown
Collaborator

Currently we add the PP Sidebar to all edit screens and for all users, which is wrong. Authors shouldn't be deciding which page serves which purpose — that's a site-level editorial decision.

This PR now does two things: hides the sidebar UI, and closes the write path behind it.

1. Don't enqueue the editor script for users who can't act on it

classes/admin/class-editor.php — require edit_others_posts before enqueueing:

if ( ! \current_user_can( 'edit_others_posts' ) ) {
    return;
}

2. Restrict the taxonomy itself

Hiding the UI alone doesn't stop anything. progress_planner_page_types is registered with show_in_rest => true and no capabilities array, so assign_terms falls back to edit_posts. Any author could set a page type on their own post through the standard POST /wp/v2/posts/{id} endpoint — no plugin UI involved. Verified on a real install:

PHP  wp_set_object_terms  = OK ["24"]
REST post-update status   = 200
REST resulting_terms      = [24]

So assign_terms is now edit_others_posts, with term management staying at manage_categories.

3. Don't let the new restriction eat unrelated edits

Raising the capability on its own introduces a worse bug. The posts controller rejects the entire request with rest_cannot_assign_term when the field is present and not permitted — so an author who merely resubmits the page type an editor already set loses every other change in that save:

S1 resubmit_same  status=403 code=rest_cannot_assign_term
S1 title_saved="Post title 0000020"   ← the author's new title was NOT saved

This is reachable in normal use: the block editor round-trips the taxonomy once it's dirty in editor state.

So this adds a rest_request_before_callbacks filter that strips the field from writes by users who can't assign it. Their save proceeds; the existing page type is left untouched. That hook is used because the taxonomy permission check runs inside the controller's *_permissions_check(), which fires before rest_pre_insert_{$post_type} would. GET requests are left alone so the taxonomy still works as a query filter.

Verified behaviour

Exercised against a real install:

Scenario Result
Author resubmits the same page type + title change 200, title saved, page type intact
Author tries to change the page type 200, title saved, page type unchanged
Author saves with no page-type field 200
GET filtering by the taxonomy 200, unaffected
Editor changes the page type 200, term updated

Scope note

The original version of this PR also restricted the sidebar to the Page edit screen. Both halves of that had to go, because #746 landed in the meantime (merged 2026-01-21, one day after this branch's last update):

  • This branch still carried the site-editor.php bail. WIP: Make the editor sidebar show up in the site-editor #746 deliberately removed it and moved the "where should the sidebar appear" decision into JS, where PrplProgressPlannerSidebar returns null for wp_template / wp_template_part.
  • The 'page' !== \get_post_type() check would break the same feature from the other side: there's no global post context in the site-editor, so it returns false at enqueue_block_editor_assets time and the script would never enqueue there.

That also settles the open question in the original description about the Blog page type — with the post-type restriction gone, posts and pages behave the same.

Verification

  • composer check-cs -- --warning-severity=6 — clean (exit 0)
  • composer phpstan — no errors
  • composer test — OK (398 tests, 1203 assertions)

There's no automated coverage of Editor::enqueue_editor_script() or the taxonomy caps in the committed suite, so the suite passing confirms nothing else regressed rather than validating these changes. The behaviour table above came from throwaway probes that were not committed — worth deciding separately whether the capability behaviour deserves permanent test coverage.

cc @jdevalk

@github-actions

github-actions Bot commented Dec 17, 2025

Copy link
Copy Markdown
Contributor

Test on Playground
Test this pull request on the Playground
or download the zip

@github-actions

github-actions Bot commented Dec 17, 2025

Copy link
Copy Markdown
Contributor

✅ Code Coverage Report

Metric Value
Total Coverage 31.72% 📉
Base Coverage 31.75%
Difference 📉 -0.03%

⚠️ Coverage below recommended 40% threshold

📊 File-level Coverage Changes (2 files)

📉 Coverage Decreased

Class Before After Change
Progress_Planner\Page_Types 52.68% 50.42% -2.26%
Progress_Planner\Admin\Editor 4.55% 4.17% -0.38%
ℹ️ About this report
  • All tests run in a single job with Xdebug coverage
  • Security tests excluded from coverage to prevent output issues
  • Coverage calculated from line coverage percentages

@ilicfilip
ilicfilip marked this pull request as ready for review December 30, 2025 13:33
ilicfilip and others added 3 commits January 12, 2026 12:43
Resolve the conflict in classes/admin/class-editor.php by keeping only
the capability check.

Two things this branch carried are dropped:

- The site-editor.php bail. #746 deliberately removed it and moved the
  "where should the sidebar appear" decision into JS, where
  PrplProgressPlannerSidebar returns null for wp_template and
  wp_template_part. Keeping the PHP bail would undo that.

- The 'page' !== get_post_type() check. In the site-editor there is no
  global post context, so get_post_type() returns false at
  enqueue_block_editor_assets time and the script would never enqueue
  there, again undoing #746.

The capability check is the part that is still needed: develop has no
capability gating in Editor at all, so every user who can open the block
editor gets the sidebar plus the localized lessons and page-types
payload.
@github-actions

Copy link
Copy Markdown
Contributor

🔍 WordPress Plugin Check Report

⚠️ Status: Passed with warnings

📊 Report

🎯 Total Issues ❌ Errors ⚠️ Warnings
10 0 10

⚠️ Warnings (10)

📁 classes/suggested-tasks/providers/class-content-review.php (4 warnings)
📍 Line 🔖 Check 💬 Message
232 WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_post__not_in Using exclusionary parameters, like post__not_in, in calls to get_posts() should be done with caution, see https://docs.wpvip.com/databases/optimize-queries/using-post__not_in/ for more information.
377 WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_post__not_in Using exclusionary parameters, like post__not_in, in calls to get_posts() should be done with caution, see https://docs.wpvip.com/databases/optimize-queries/using-post__not_in/ for more information.
381 WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_post__not_in Using exclusionary parameters, like post__not_in, in calls to get_posts() should be done with caution, see https://docs.wpvip.com/databases/optimize-queries/using-post__not_in/ for more information.
388 WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_post__not_in Using exclusionary parameters, like post__not_in, in calls to get_posts() should be done with caution, see https://docs.wpvip.com/databases/optimize-queries/using-post__not_in/ for more information.
📁 classes/suggested-tasks/data-collector/class-unpublished-content.php (1 warning)
📍 Line 🔖 Check 💬 Message
103 WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_post__not_in Using exclusionary parameters, like post__not_in, in calls to get_posts() should be done with caution, see https://docs.wpvip.com/databases/optimize-queries/using-post__not_in/ for more information.
📁 classes/suggested-tasks/data-collector/class-yoast-orphaned-content.php (1 warning)
📍 Line 🔖 Check 💬 Message
111 PluginCheck.Security.DirectDB.UnescapedDBParameter Unescaped parameter $query used in $wpdb->get_row()\n$query assigned unsafely at line 98.
📁 classes/suggested-tasks/data-collector/class-terms-without-description.php (1 warning)
📍 Line 🔖 Check 💬 Message
108 PluginCheck.Security.DirectDB.UnescapedDBParameter Unescaped parameter $query used in $wpdb->get_results()\n$query assigned unsafely at line 106.
📁 classes/suggested-tasks/data-collector/class-terms-without-posts.php (1 warning)
📍 Line 🔖 Check 💬 Message
120 PluginCheck.Security.DirectDB.UnescapedDBParameter Unescaped parameter $query used in $wpdb->get_results()\n$query assigned unsafely at line 118.
📁 classes/activities/class-query.php (2 warnings)
📍 Line 🔖 Check 💬 Message
71 PluginCheck.Security.DirectDB.UnescapedDBParameter Unescaped parameter $table_name used in $wpdb->query()\n$table_name assigned unsafely at line 58.
163 PluginCheck.Security.DirectDB.UnescapedDBParameter Unescaped parameter $where_args used in $wpdb->get_results()\n$where_args assigned unsafely at line 153.

🤖 Generated by WordPress Plugin Check Action • Learn more about Plugin Check

@ilicfilip

Copy link
Copy Markdown
Collaborator Author

Brought this up to date with develop and narrowed the scope. Ready to merge whenever you want it — all 22 checks pass, mergeStateStatus: CLEAN. Leaving the merge to you.

What changed

The branch was 86 commits behind and conflicted in classes/admin/class-editor.php — the one file it touches. Merged develop in and resolved that conflict.

Kept: the edit_others_posts capability check. Assigning a page type is an editorial decision about which page serves which purpose, so it should be at least editor level rather than something an author does on their own posts.

Dropped: the 'page' !== \get_post_type() restriction, and the site-editor.php bail this branch was still carrying. Both would have broken #746, which merged 2026-01-21 — one day after this branch's last update:

  • WIP: Make the editor sidebar show up in the site-editor #746 deliberately removed that site-editor.php bail and moved the "where should the sidebar appear" decision into JS, where PrplProgressPlannerSidebar returns null for wp_template / wp_template_part. Re-adding the PHP bail would undo it.
  • The get_post_type() check would break the same feature from the other side: there's no global post context in the site-editor, so it returns false at enqueue_block_editor_assets time and the script would never enqueue there.

That also settles the open question in the original description about the Blog page type — with the post-type restriction gone, posts and pages behave the same, and where the sidebar renders is JS's call per #746. Nothing further needed on that.

Net change

Six lines, one file:

// Assigning a page-type is an editorial decision, so require the capability
// to edit others' posts. Authors editing their own posts don't get the sidebar.
if ( ! \current_user_can( 'edit_others_posts' ) ) {
    return;
}

One thing worth knowing before merging

This changes who sees the sidebar, so it's a visible behaviour change for Authors and Contributors — they lose it entirely.

On severity: develop currently has no capability gating in Editor at all, so today every user who can open the block editor also receives the wp_localize_script payload (full lessons list, page types, branding icon). But the progress_planner_page_types taxonomy is registered without a custom capabilities array, so assign_terms falls back to edit_posts — meaning a lower-privileged user could already assign a page type server-side. So this stops showing the UI and shipping the payload to people who shouldn't be making the call; it does not close a privilege-escalation hole. Tightening the taxonomy's own capabilities would be a separate change if we want that.

Verification

  • composer check-cs -- --warning-severity=6 — clean (exit 0)
  • composer phpstan — no errors
  • composer test — OK (398 tests, 1203 assertions)
  • CI: 22/22 passing, including both e2e suites and integration tests across PHP 8.2–8.4 on WP 6.7/latest/multisite

There's no automated coverage of Editor::enqueue_editor_script() itself, so the suite passing confirms nothing else regressed rather than validating this change directly. The capability behaviour hasn't been manually exercised against a real Author account.

The sidebar guard in class-editor.php only hides the UI. The write path
stayed open: progress_planner_page_types is registered with
show_in_rest and no capabilities array, so assign_terms fell back to
edit_posts and any author could set a page type on their own post
through the standard REST post endpoint -- no plugin UI involved.

Set assign_terms to edit_others_posts. Saying what a page is *for* is a
site-level editorial decision, not something an author decides for their
own post.

Raising the capability alone would break ordinary editing, though. The
posts controller rejects the whole request with rest_cannot_assign_term
when the field is present and not permitted, so an author who merely
resubmits the page type an editor already set would lose every other
change in that save -- title and content included. The block editor
round-trips the field once it is dirty in editor state, so this is
reachable in normal use.

Add a rest_request_before_callbacks filter that strips the field from
writes by users who cannot assign it, so their save proceeds and the
existing page type is left untouched. That hook is used because the
taxonomy permission check runs in the controller's permissions_check(),
before rest_pre_insert_{$post_type} would fire. GET requests are left
alone so the taxonomy still works as a query filter.

Verified against a real install -- author resubmitting the same term,
author attempting to change it, author saving with no term, GET
filtering, and an editor changing the term.
@ilicfilip ilicfilip changed the title Dont enqueue Editor script (sidebar) for all users Restrict page types to editors: hide sidebar and lock down the taxonomy Aug 25, 2026
@ilicfilip

Copy link
Copy Markdown
Collaborator Author

Extended this to actually close the write path, not just hide the UI. Still ready to merge — 22/22 checks pass, mergeStateStatus: CLEAN.

Why the extra commit

The sidebar guard alone doesn't prevent anything. progress_planner_page_types is registered with show_in_rest => true and no capabilities array, so assign_terms fell back to edit_posts — an author could set a page type on their own post through the plain POST /wp/v2/posts/{id} endpoint, with no plugin UI involved. Confirmed on a real install before changing anything:

REST post-update status = 200
REST resulting_terms    = [24]

So assign_terms is now edit_others_posts (term management stays at manage_categories).

The part that needed care

Raising the capability on its own introduces a worse bug than it fixes. The posts controller rejects the whole request with rest_cannot_assign_term when the field is present and not permitted — so an author who merely resubmits the page type an editor already set loses every other change in that save:

S1 resubmit_same  status=403 code=rest_cannot_assign_term
S1 title_saved="Post title 0000020"    ← the author's new title was NOT saved

That's reachable in normal use, since the block editor round-trips the taxonomy once it's dirty in editor state.

So there's a rest_request_before_callbacks filter that strips the field from writes by users who can't assign it — their save goes through, the existing page type is untouched. That specific hook because the taxonomy permission check runs inside the controller's *_permissions_check(), which fires before rest_pre_insert_{$post_type} would. GET is left alone so the taxonomy still works as a query filter.

Verified

Scenario Result
Author resubmits same page type + title change 200, title saved, page type intact
Author tries to change the page type 200, title saved, page type unchanged
Author saves with no page-type field 200
GET filtering by the taxonomy 200, unaffected
Editor changes the page type 200, term updated

Note on the CI run

The first run after this commit showed 4 failures. They're a pre-existing flake, not this change: Cache_Integration_Test::test_cache_expiration sets a 1-second cache TTL and then asserts assertGreaterThan( time(), $timeout ). When the clock ticks between those two time() calls both land in the same second and the strict > fails — Failed asserting that 1787663271 is greater than 1787663271. Re-ran and both workflows went green. Worth tightening that assertion separately if it keeps surfacing.

Scope

Also checked whether authors could reach anything else: the two plugin REST routes with permission_callback => '__return_true' are token-gated via a required token arg, and progress_planner_page_todos is per-post content on the author's own post. Page types were the only site-level setting exposed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants