Skip to content

PRO-1239: Enforce ignore/dismiss permission with a real capability - #1855

Open
pattonwebz wants to merge 24 commits into
developfrom
william/pro-1239-enforce-ignoredismiss-permission-with-a-real-capability
Open

PRO-1239: Enforce ignore/dismiss permission with a real capability#1855
pattonwebz wants to merge 24 commits into
developfrom
william/pro-1239-enforce-ignoredismiss-permission-with-a-real-capability

Conversation

@pattonwebz

@pattonwebz pattonwebz commented Jul 28, 2026

Copy link
Copy Markdown
Member

Summary

Introduces a real, synced WordPress capability (edac_ignore_issues) as the single source of truth for who can dismiss/ignore accessibility issues, replacing the ad-hoc edac_user_can_ignore() role-intersection check that wasn't consistently enforced everywhere.

  • edac_user_can_ignore() now just checks current_user_can('edac_ignore_issues')
  • The capability is synced onto exactly the roles configured in edacp_ignore_user_roles whenever that option is saved, with a map_meta_cap bypass so manage_options users always pass, plus a one-time migration for existing installs
  • Enforced at every dismiss/ignore call site that was missing it: the Fast-Track AJAX handler (was defaulting to true), the bulk-ignore AJAX handler (had no ignore-capability check), and the REST dismiss-issue route (permission callback + handler)
  • Editor sidebar and issue modal UI now hide the dismiss form/actions (with an explanatory notice) for users without the capability, via a new canDismiss config flag
  • Tests for the capability sync behavior and for REST enforcement

Related

  • Companion PR in the pro plugin: equalizedigital/accessibility-checker-pro#(pro PR number)
  • Linear: PRO-1239

Test plan

  • Configure edacp_ignore_user_roles to a role, confirm that role gains edac_ignore_issues and can dismiss; confirm a role left out cannot (gets a notice in the UI, 403 from REST/AJAX)
  • Confirm manage_options users can always dismiss regardless of role config
  • Confirm existing installs (option already set before this change) get migrated on next admin_init without needing to re-save the setting
  • npm run lint, PHPUnit

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added configurable permissions for dismissing and reopening accessibility issues.
    • Added separate permissions for dismissing issues across multiple posts and accessing the Issues Explorer.
    • Administrators retain access, while permitted roles can be managed through plugin settings.
    • The issue panel indicates when dismissal actions are unavailable.
  • Bug Fixes

    • Prevented unauthorized users from dismissing or reopening issues.
    • Ensured bulk dismissals require the appropriate permission.
  • Tests

    • Added coverage for role-based permissions, administrator access, settings synchronization, and unauthorized API requests.

pattonwebz and others added 6 commits July 28, 2026 18:39
…oles

edac_user_can_ignore() previously recomputed a role-array intersection
on every call and returned it directly (array|false, not bool). Replace
it with current_user_can('edac_ignore_issues'), a capability synced
onto exactly the allowed roles whenever edacp_ignore_user_roles is
saved, plus a map_meta_cap bypass for manage_options and a one-time
migration for sites that already have the option set. Gives every
future call site (REST, AJAX, menu registration) one consistent,
strictly-boolean check instead of duplicating the role logic.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The Fast-Track quick-ignore filter defaulted edac_ignore_permission to
true instead of checking the current user, and the bulk-ignore AJAX
handler had no ignore-capability check at all (only the edit_post
check on individual posts). Both now gate on edac_user_can_ignore().

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Add the capability check to both the route's permission_callback and
dismiss_issue() itself, so a user with edit_post on the target post
but no ignore permission gets a 403 rather than being allowed through
on edit_post alone.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Expose edac_user_can_ignore() to the sidebar's JS config so the
dismiss UI can react to the current user's permission instead of
assuming every logged-in user can dismiss.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
DismissPanel now reads a canDismiss prop (default true, so existing
usages are unaffected) and shows a "You do not have permission to
dismiss issues" notice instead of the dismiss form/actions when false.
IssueDetailsModal wires it from window.edac_sidebar_app.canDismiss.

Extracted the isIgnored/canDismiss branching into a dismissBody
variable (matching the existing panelTitle pattern) instead of a
three-way ternary, since ESLint's no-nested-ternary rule flags nested
ternaries even when parenthesized.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
IgnoreCapabilityTest covers the role add/remove sync behavior when
edacp_ignore_user_roles is saved. RestApiEndpointsTest gains a case
proving a user with edit_post but no edac_ignore_issues still gets a
403 on dismiss-issue, and the existing edit_post-authorization test
fixture is updated to grant edac_ignore_issues explicitly so it keeps
testing edit_post authorization in isolation from the new check.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@pattonwebz

Copy link
Copy Markdown
Member Author

Companion PR: equalizedigital/accessibility-checker-pro#832

@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Ignore permissions now use synchronized capabilities, with support for direct user grants. AJAX, REST, and sidebar dismissal flows enforce individual and global permissions. PHPUnit tests cover synchronization, migration, authorization, and capability grants.

Changes

Ignore capability authorization

Layer / File(s) Summary
Ignore capability foundation
includes/classes/Capabilities/*, includes/options-page.php, tests/phpunit/includes/IgnoreCapabilityTest.php, tests/phpunit/includes/classes/Capabilities/*Test.php
Adds capability checking, role synchronization, administrator bypasses, option hooks, migration handling, and three bundled ignore capabilities.
Individual capability grants
includes/classes/Capabilities/UserCapabilityGrant.php, tests/phpunit/includes/classes/Capabilities/UserCapabilityGrantTest.php
Adds direct user capability grants and revocation with attribution metadata. Tests distinguish direct grants from role-derived capabilities.
Endpoint authorization
admin/class-ajax.php, includes/classes/class-rest-api.php, tests/phpunit/includes/classes/RestApiEndpointsTest.php
Blocks unauthorized ignore requests. Large-batch REST dismissal requires global ignore capability and can bypass per-post checks when authorized.
Sidebar dismissal UI
admin/class-enqueue-admin.php, src/issueModal/components/DismissPanel.js, src/issueModal/components/IssueDetailsModal.js
Localizes dismissal permissions, passes them into the issue modal, and gates dismissal controls and global actions.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Caller
  participant DismissEndpoint
  participant CapabilityChecker
  participant IssueStore
  Caller->>DismissEndpoint: submit dismiss request
  DismissEndpoint->>CapabilityChecker: check ignore capability
  CapabilityChecker-->>DismissEndpoint: permission result
  DismissEndpoint->>IssueStore: update issue when permitted
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: enforcing ignore and dismissal permissions through a real capability.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch william/pro-1239-enforce-ignoredismiss-permission-with-a-real-capability

Comment @coderabbitai help to get the list of available commands.

pattonwebz and others added 3 commits July 28, 2026 20:12
Generalizes the sync/manage_options-bypass/version-gated-migration
trio that edac_ignore_issues hand-rolled in options-page.php, so
future role-configurable features (and the pro plugin's REST routes
that currently copy-paste inline current_user_can() closures) can
reuse it instead of re-implementing the same pattern.

Deliberately not generalized further than the one real use case
demands: the role source is a plain option name (not an injected
callable) and the admin bypass is hardcoded to manage_options (not a
constructor parameter) — both would be solving hypothetical second
cases that don't exist yet.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Replaces the hand-rolled sync/bypass/migration functions with a single
Synced_Capability instance behind an edac_ignore_capability() accessor;
edac_user_can_ignore() now just delegates to it. No behavior change —
same capability name, option name, default role, and migration
semantics as before, just de-duplicated.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Uses a throwaway capability/option pair, independent of
edac_ignore_issues, to cover sync/register/manage_options-bypass/
permission_callback plus the migration behavior that had no direct
test coverage before this class existed: initial migration on an
unset option, no re-run at the same version, and a version bump
forcing re-sync.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@pattonwebz

Copy link
Copy Markdown
Member Author

Pushed 3 more commits adding `Synced_Capability` — a small reusable class extracted from this PR's own `edac_ignore_issues` wiring (sync onto roles from an option + `manage_options` bypass + version-gated migration), so future role-configurable features and the pro plugin's REST routes (currently full of copy-pasted inline `current_user_can()` closures) can reuse the pattern instead of hand-rolling it again.

Deliberately kept narrow: the role source is a plain option name rather than an injected callable, and the admin bypass is hardcoded to `manage_options` rather than a constructor parameter — both would be generalizing for a hypothetical second case that doesn't exist yet given there's exactly one real caller today.

`edac_ignore_issues` itself now just instantiates this class — no behavior change, same capability/option/default-role/migration semantics as before. Added standalone tests for the class independent of that one feature, plus fixed up `IgnoreCapabilityTest` to call through the new API.

Full suite: 887 tests passing, 15 pre-existing skips.

🤖 Generated with Claude Code

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@includes/classes/Capabilities/Synced_Capability.php`:
- Around line 8-21: Rename the Synced_Capability class and its file to
SyncedCapability.php, following the project’s CamelCase convention. Update all
references, including the use statements in options-page.php and
SyncedCapabilityTest.php, while preserving the class behavior and namespace.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 021f446a-6aba-4fb8-bee0-e1d7d08b50e8

📥 Commits

Reviewing files that changed from the base of the PR and between 9ea08a8 and 290778b.

📒 Files selected for processing (4)
  • includes/classes/Capabilities/Synced_Capability.php
  • includes/options-page.php
  • tests/phpunit/includes/IgnoreCapabilityTest.php
  • tests/phpunit/includes/classes/Capabilities/SyncedCapabilityTest.php
🚧 Files skipped from review as they are similar to previous changes (1)
  • tests/phpunit/includes/IgnoreCapabilityTest.php

Comment on lines +8 to +21
namespace EqualizeDigital\AccessibilityChecker\Capabilities;

/**
* Registers a real WordPress capability, kept in sync with the role list
* stored in a plugin option, with a manage_options bypass and a
* version-gated migration for sites that already had the option set
* before the capability existed.
*
* Extracted from the hand-rolled edac_ignore_issues wiring in
* includes/options-page.php so other gated features (Explorer REST
* routes, future role-configurable features) can reuse the same pattern
* instead of re-implementing the sync/bypass/migration trio each time.
*/
class Synced_Capability {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Class name doesn't follow the project's CamelCase convention.

Synced_Capability uses underscore separation, but the existing codebase pattern (e.g. FixesPage used alongside this in includes/options-page.php) and the coding guideline both call for CamelCase class names. Since this class is explicitly designed for reuse by future gated features, renaming now (before more consumers adopt it) avoids inconsistency later. This would require updating the use statements in includes/options-page.php and tests/phpunit/includes/classes/Capabilities/SyncedCapabilityTest.php as well.

As per coding guidelines, "Use CamelCase naming convention for new class names (e.g., ClassName.php)".

♻️ Proposed rename
-class Synced_Capability {
+class SyncedCapability {

Also rename the file to SyncedCapability.php and update the two consuming use statements.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@includes/classes/Capabilities/Synced_Capability.php` around lines 8 - 21,
Rename the Synced_Capability class and its file to SyncedCapability.php,
following the project’s CamelCase convention. Update all references, including
the use statements in options-page.php and SyncedCapabilityTest.php, while
preserving the class behavior and namespace.

Source: Coding guidelines

pattonwebz and others added 2 commits July 28, 2026 22:27
William asked whether a capability could be granted to an individual
user (not just via role), and whether an admin could see who granted
it. Synced_Capability doesn't need to change for this: its sync() only
touches role objects (wp_user_roles option), while a capability added
directly to a user via $user->add_cap() lives in that user's own
wp_capabilities meta — separate storage that current_user_can()/
user_can() already merges. A role-level sync can never clobber an
individual grant.

What WordPress doesn't provide is attribution — no concept of who
granted a capability or when. User_Capability_Grant adds that as a
thin layer: grant()/revoke() wrap add_cap()/remove_cap() and record
{granted_by, granted_at} in user meta; get_grant_info() surfaces it for
a future admin UI; is_individually_granted() distinguishes "granted
directly to this user" from "has it via their role" (both pass
user_can() identically, only the former reads $user->caps rather than
the role-merged $user->allcaps).

Works with any capability string, not coupled to edac_ignore_issues or
Synced_Capability. No admin UI yet — this is the underlying mechanism
a future grant/revoke screen would call.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Covers grant/revoke, attribution recording and defaulting to the
current user, revoke clearing attribution, is_individually_granted()
distinguishing a direct grant from a role-derived capability, graceful
handling of a nonexistent user ID, and the key coexistence guarantee:
a direct grant survives a role-level capability removal (proving
Synced_Capability-style role sync can never clobber it, without
depending on that class).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@pattonwebz

Copy link
Copy Markdown
Member Author

Pushed 2 more commits adding `User_Capability_Grant` — a small class for granting a capability directly to an individual user (independent of their role) with attribution (who granted it, when).

This came out of a design question during review: does `Synced_Capability` need to change to support per-user grants? Turns out no — its `sync()` only ever touches role objects (`wp_user_roles` option), while a capability added directly to a user via `$user->add_cap()` lives in that user's own `wp_capabilities` meta, completely separate storage that `current_user_can()`/`user_can()` already merges automatically. A role-level sync can never clobber an individual grant — proven directly in the new test suite (`test_direct_grant_survives_role_level_capability_removal`).

What WordPress genuinely lacks is attribution — no concept of who granted a capability or when. `User_Capability_Grant::grant()/revoke()/get_grant_info()/is_individually_granted()` adds that as a thin, capability-agnostic layer (works with any capability string, not coupled to `edac_ignore_issues`). No admin UI yet — this is the underlying mechanism a future grant/revoke screen would call.

Full suite: 896 tests passing, 15 pre-existing skips.

🤖 Generated with Claude Code

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@includes/classes/Capabilities/User_Capability_Grant.php`:
- Around line 48-63: Update User_Capability_Grant::grant() and revoke() to track
whether this class added the capability, rather than attributing every
pre-existing capability assignment to itself. Persist that ownership flag with
the grant metadata, and make revoke() call remove_cap() only when the flag
indicates this class added it; preserve metadata cleanup and add a regression
test covering a pre-existing direct capability that remains after grant/revoke.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 79665a82-9e00-443e-8ced-e03b8572ace2

📥 Commits

Reviewing files that changed from the base of the PR and between 290778b and d81b817.

📒 Files selected for processing (2)
  • includes/classes/Capabilities/User_Capability_Grant.php
  • tests/phpunit/includes/classes/Capabilities/UserCapabilityGrantTest.php

Comment on lines +48 to +63
public static function grant( int $user_id, string $capability, int $granted_by = 0 ): bool {
$user = get_userdata( $user_id );
if ( ! $user ) {
return false;
}

$user->add_cap( $capability );

update_user_meta(
$user_id,
self::META_PREFIX . $capability,
[
'granted_by' => $granted_by ? $granted_by : get_current_user_id(),
'granted_at' => time(),
]
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Do not revoke a pre-existing direct capability.

grant() records attribution even when another plugin already assigned the capability directly, while revoke() always removes it. Granting and then revoking through this class can therefore strip an unrelated direct permission. Record whether this class actually added the capability, and only call remove_cap() when that flag is true; add a regression test for this case.

Proposed direction
+ $was_directly_granted = ! empty( $user->caps[ $capability ] );
  $user->add_cap( $capability );

  update_user_meta(
    $user_id,
    self::META_PREFIX . $capability,
    [
      'granted_by' => $granted_by ? $granted_by : get_current_user_id(),
      'granted_at' => time(),
+     'added_by_this_grant' => ! $was_directly_granted,
    ]
  );

+ $grant_info = self::get_grant_info( $user_id, $capability );
- $user->remove_cap( $capability );
+ if ( ! empty( $grant_info['added_by_this_grant'] ) ) {
+   $user->remove_cap( $capability );
+ }
  delete_user_meta( $user_id, self::META_PREFIX . $capability );

Also applies to: 82-83

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@includes/classes/Capabilities/User_Capability_Grant.php` around lines 48 -
63, Update User_Capability_Grant::grant() and revoke() to track whether this
class added the capability, rather than attributing every pre-existing
capability assignment to itself. Persist that ownership flag with the grant
metadata, and make revoke() call remove_cap() only when the flag indicates this
class added it; preserve metadata cleanup and add a regression test covering a
pre-existing direct capability that remains after grant/revoke.

…UserCapabilityGrant

Match the codebase's PSR-4 class-naming convention (PascalCase, no
underscores — see FixesManager, Connector) rather than the legacy
underscore-separated style used by the older EDAC\Admin\* classes.
Renames files, class names, and every reference (includes/options-page.php,
both test suites), no behavior change.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@pattonwebz

Copy link
Copy Markdown
Member Author

Pushed a rename: `Synced_Capability` → `SyncCapability`, `User_Capability_Grant` → `UserCapabilityGrant` (files, class names, and every reference — no behavior change). Matches this codebase's PSR-4 class-naming convention (PascalCase, no underscores — same as `FixesManager`, `Connector`) rather than the legacy underscore style. `CAPABILITY_MAP.md` updated to match throughout.

Full suite: 896 tests passing, 15 pre-existing skips.

Also filed 7 Linear cards (PRO-1254–PRO-1260) for the capability-addition requests from William's review — Settings/Fixes capability, frontend highlighter capability, Full Site Scan + save-results capabilities, simplified-summary capability (low priority), the license REST route normalization onto `AbstractRestRoute`, multisite/export sync with `edac_ignore_issues`, and an Export-plugin capability. None of those are code changes in this PR — future work, linked back to PRO-1239 for context.

🤖 Generated with Claude Code

pattonwebz and others added 8 commits July 29, 2026 15:51
Splits SyncCapability into a pure writer that can sync N capabilities
from one option (via a generic sync_role_capability() primitive) and a
new agnostic CapabilityChecker reader class, so the upcoming
ignore/global-ignore/explorer-access capability split can share one
option and one migration instead of three independent instances.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Bundles edac_ignore_issues with two new capabilities -
edac_ignore_issues_globally and edac_issues_explorer_access - onto the
existing edacp_ignore_user_roles option/roles, rather than adding a
separate settings control for each. Adds edac_user_can_ignore_globally()
and edac_user_can_access_issues_explorer() helpers alongside the
existing edac_user_can_ignore(), all routed through CapabilityChecker.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
sync_role_capability() is now private - calling it directly for one
capability out of a bundle would grant/revoke that one while leaving
the rest of the bundle out of sync for that role, contradicting the
class's own guarantee that bundled capabilities always travel together.

The migration-version marker is now keyed by option_name plus a hash of
the capability set, not option_name alone, so two SyncCapability
instances that ever point at the same option can't silently share one
version counter and skip each other's migration.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
dismiss_issue()'s largeBatch path is what actually performs a global
ignore (updating every row sharing an object across every post), but
was only gated by the ordinary per-post edac_ignore_issues capability
plus a per-post edit_post loop - neither of which represents "allowed
to take a global action" specifically. A role granted edac_ignore_issues
and edac_issues_explorer_access but not edac_ignore_issues_globally
could still perform the real global suppression through this endpoint,
since pro's separate /global-ignore route (correctly gated on the new
capability) only syncs a persistence table, not the actual issue rows.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
DismissPanel showed its "Dismiss Globally" / "Remove Global Dismissal"
controls whenever isPro was true, regardless of whether the current
user actually has edac_ignore_issues_globally - the only consequence
was the underlying REST call failing after the new server-side check,
but the control shouldn't be offered at all to a user who can't use it.
Adds a canDismissGlobally prop (sourced from edac_user_can_ignore_globally()
via wp_localize_script) that both the free plugin's per-post issue modal
and pro's Issues Explorer now pass through.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
admin_menu (where menu capability checks happen) and rest_api_init
(where REST permission_callbacks are registered) both fire before
admin_init on their respective request types, so migrating on
admin_init left the first request after a version bump building a menu,
or serving a REST request, against pre-migration capabilities - and
REST-only requests never fire admin_init at all. init fires early
enough on every request type to have already run by the time any of
those checks happen.

Also throws if SyncCapability is ever constructed with zero
capabilities, rather than silently passing null to current_user_can()
from user_can()'s no-argument default.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
A largeBatch request already requires edac_ignore_issues_globally to
reach this code at all - re-checking edit_post per affected post for a
user who already holds that capability is redundant work (and, more
importantly, blocks the capability from doing what it's for: letting a
trusted role act on posts they don't personally own). The loop stays as
a fallback for any caller that somehow reaches this branch without the
capability.

Updates the largeBatch tests accordingly: what was "authorized on some"
now proves the bypass covers posts the user doesn't own, and the
previously-shared edac_ignore_issues_globally grant on the test fixture
user moves to per-test so tests that still want the fallback loop
exercised (partial-post-ownership blocked) aren't accidentally
short-circuited by it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…s branch

SyncCapabilityTest: the constructor rejecting an empty capabilities array
and register() hooking maybe_migrate() to init (not admin_init) were both
new behavior in 3816bc2 with no direct test coverage.

DismissPanel: the existing 'keeps global undo available' test predates
the canDismissGlobally prop and was asserting on the old isPro-only gate,
which now fails - a Pro user's isPro flag alone no longer implies they
can see the global-dismiss/undo controls after d9da482. Updated it to
pass canDismissGlobally, and added tests for the capability actually
gating both the initiate and undo controls independent of isPro.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
includes/classes/class-rest-api.php (1)

336-358: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Global-ignore bypass is incomplete: the permission_callback still gates on edit_post for the URL's representative issue, and no test catches it. The root cause is in the permission_callback; the test gap is a direct consequence of never exercising a representative issue on a post the actor can't edit.

  • includes/classes/class-rest-api.php#L336-L358: add a bypass so that when largeBatch is true and edac_user_can_ignore_globally() is true, the permission_callback returns true without requiring current_user_can( 'edit_post', $post_id ) on the representative issue's post (see the diff in the per-site comment above).
  • tests/phpunit/includes/classes/RestApiEndpointsTest.php#L904-L1006: extend test_large_batch_dismiss_bypasses_per_post_check_with_global_capability (or add a new test) so the request's URL issue_id is the one on $admin_post (a post the acting user cannot edit), not $limited_post, so the test actually exercises the permission_callback's global bypass path once fixed.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@includes/classes/class-rest-api.php` around lines 336 - 358, The
permission_callback in includes/classes/class-rest-api.php lines 336-358 must
return true for largeBatch requests when edac_user_can_ignore_globally() is
true, without requiring edit_post on the representative issue; retain existing
validation for other requests. Update
tests/phpunit/includes/classes/RestApiEndpointsTest.php lines 904-1006 by
extending
test_large_batch_dismiss_bypasses_per_post_check_with_global_capability (or
adding a test) so the request uses the issue_id from $admin_post, exercising the
global bypass with a post the actor cannot edit.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@includes/classes/class-rest-api.php`:
- Around line 336-358: The permission_callback in
includes/classes/class-rest-api.php lines 336-358 must return true for
largeBatch requests when edac_user_can_ignore_globally() is true, without
requiring edit_post on the representative issue; retain existing validation for
other requests. Update tests/phpunit/includes/classes/RestApiEndpointsTest.php
lines 904-1006 by extending
test_large_batch_dismiss_bypasses_per_post_check_with_global_capability (or
adding a test) so the request uses the issue_id from $admin_post, exercising the
global bypass with a post the actor cannot edit.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 7aa3648a-e2ad-4277-a6f3-59abf5bc4a48

📥 Commits

Reviewing files that changed from the base of the PR and between 5ade146 and 37958d4.

📒 Files selected for processing (11)
  • admin/class-enqueue-admin.php
  • includes/classes/Capabilities/CapabilityChecker.php
  • includes/classes/Capabilities/SyncCapability.php
  • includes/classes/class-rest-api.php
  • includes/options-page.php
  • src/issueModal/components/DismissPanel.js
  • src/issueModal/components/IssueDetailsModal.js
  • tests/phpunit/includes/IgnoreCapabilityTest.php
  • tests/phpunit/includes/classes/Capabilities/CapabilityCheckerTest.php
  • tests/phpunit/includes/classes/Capabilities/SyncCapabilityTest.php
  • tests/phpunit/includes/classes/RestApiEndpointsTest.php
🚧 Files skipped from review as they are similar to previous changes (3)
  • src/issueModal/components/IssueDetailsModal.js
  • admin/class-enqueue-admin.php
  • src/issueModal/components/DismissPanel.js

pattonwebz and others added 2 commits August 3, 2026 20:28
register() only hooked add_option/update_option, so deleting the
option (e.g. Pro's uninstall routine, gated behind the "delete data"
preference) left every role that had been granted the bundle stuck
with it indefinitely - the old array_intersect()-against-get_option()
check re-read the option live on every request, so this is a real
behavior change from before: deleting the option used to revoke
access instantly for everyone but admins, and now it wouldn't at all.
Hooking delete_option_{$option_name} to sync([]) closes that gap.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
No call site in either plugin ever used grant()/revoke()/get_grant_info() -
per-user capability overrides aren't a planned feature, and site owners who
want that already have WordPress's own add_cap()/remove_cap() or a
role-editor plugin. Speculative infrastructure with no consumer and no
near-term plan isn't worth the maintenance surface.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
pattonwebz and others added 2 commits August 3, 2026 21:28
… post

The dismiss-issue route's permission_callback unconditionally required
current_user_can('edit_post', $post_id) against whichever post the
URL's issue_id resolved to - dismiss_issue() itself already bypasses
the per-post edit_post loop for a user with edac_ignore_issues_globally,
but that bypass never got a chance to run if the one representative
post in the URL wasn't personally editable by the caller, since the
permission_callback rejected the request first.

Flagged by CodeRabbit on PR #1855; the existing largeBatch-bypass test
didn't catch it because its URL issue_id happened to resolve to a post
the actor could already edit. Added a test that puts the URL's issue_id
on a post only the global capability - not personal ownership - can
unlock.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…-develop

# Conflicts:
#	admin/class-ajax.php
#	tests/phpunit/includes/classes/RestApiEndpointsTest.php
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.

1 participant