Skip to content

Feat/add role user access controls - #749

Open
Infinite-Null wants to merge 72 commits into
WordPress:developfrom
Infinite-Null:feat/add-role-user-access-controls
Open

Infinite-Null wants to merge 72 commits into
WordPress:developfrom
Infinite-Null:feat/add-role-user-access-controls

Conversation

@Infinite-Null

@Infinite-Null Infinite-Null commented Jun 19, 2026 •

Copy link
Copy Markdown
Contributor

Closes: #736

Description

This PR introduces Feature-Level Access Controls exclusively for Editor Experiments, allowing site administrators to restrict access to individual AI-powered editor features based on specific WordPress roles or individual users.

Testing Instructions

  1. Navigate to AI Settings and enable Enable AI.
  2. Open the Developer Tools menu (three dots) and select Access controls.
  3. Under the "Editor Experiments" section, verify that the Roles and Users configuration panel appears beneath the feature toggles.
  4. Check a few roles (e.g., Editor, Author) for a specific feature and click Save. Verify the save is successful.
  5. In the Users field, search for a specific user and select them from the dropdown. Click Save.
  6. Refresh the page and verify that your selected roles and users persisted and display their correct names.
  7. Turn off "Access controls" from the Developer Tools menu, and verify that all selections are cleared.
  8. Verify Enforcements: Log in to the site as a user who does not possess the selected role and is not the selected user. Open the block editor and verify that the restricted experiment is not accessible or visible to them.
  9. Log in as the specifically selected user or a user with the granted role, and verify the feature is fully accessible.
  10. Turn off "Access controls" from the Developer Tools menu, and verify that all selections are cleared and the feature becomes globally available again.

Screencast

User: Ankit Shah | Role: Editor

Screen.Recording.2026-06-22.at.1.35.17.PM-compressed.mp4

Use of AI Tools

AI assistance: Yes
Tool(s): Claude Code
Model(s): Sonnet 4.6
Used for: Validating bug, suggesting a fix.

Changelog Entry

Add - Role and User based access controls for experiments

Open WordPress Playground Preview

@Infinite-Null
Infinite-Null marked this pull request as draft June 19, 2026 13:05
@github-actions

github-actions Bot commented Jun 19, 2026 •

Copy link
Copy Markdown

The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the props-bot label.

If you're merging code through a pull request on GitHub, copy and paste the following into the bottom of the merge commit message.

Co-authored-by: Infinite-Null <ankitkumarshah@git.wordpress.org>
Co-authored-by: jeffpaul <jeffpaul@git.wordpress.org>
Co-authored-by: dkotter <dkotter@git.wordpress.org>
Co-authored-by: henryperkins <htperkins@git.wordpress.org>

To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook.

@Infinite-Null
Infinite-Null marked this pull request as ready for review June 22, 2026 08:12
@codecov

codecov Bot commented Jun 29, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 84.12698% with 30 lines in your changes missing coverage. Please review.
✅ Project coverage is 84.16%. Comparing base (26322c3) to head (bddea88).
⚠️ Report is 19 commits behind head on develop.

Files with missing lines Patch % Lines
includes/Settings/Settings_Registration.php 67.92% 17 Missing ⚠️
includes/REST/Roles_Users_Controller.php 91.04% 6 Missing ⚠️
includes/Abstracts/Abstract_Feature.php 81.25% 3 Missing ⚠️
includes/helpers.php 88.00% 3 Missing ⚠️
...iments/Alt_Text_Generation/Alt_Text_Generation.php 80.00% 1 Missing ⚠️
Additional details and impacted files
@@              Coverage Diff              @@
##             develop     #749      +/-   ##
=============================================
+ Coverage      82.17%   84.16%   +1.98%     
- Complexity      3399     3547     +148     
=============================================
  Files            136      140       +4     
  Lines          13187    13651     +464     
=============================================
+ Hits           10837    11489     +652     
+ Misses          2350     2162     -188     
Flag Coverage Δ
unit 84.16% <84.12%> (+1.98%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@jeffpaul

Copy link
Copy Markdown
Member

@Infinite-Null FYI some merge conflicts to resolve to keep this moving along

@jeffpaul jeffpaul mentioned this pull request Jul 13, 2026
27 of 28 tasks
@Infinite-Null
Infinite-Null requested a review from a team as a code owner July 14, 2026 13:11
@Infinite-Null

Copy link
Copy Markdown
Contributor Author

The failing tests seem to be unrelated to the changes introduced in this PR.

@dkotter dkotter 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.

Handful of things that still need addressed here.

I've tested and things appear to work fine though I do have a question/concern on the default state.

When I first toggle on Access controls no user roles are checked. This makes it appear like no one should have access but our default here is if nothing is set, everyone has access. I wonder if that should be the default?

Either we need to make that more clear in the UI (maybe you need to turn on Access controls and then separately toggle that on for each individual feature? Or just better messaging here if nothing is selected it tells you everyone has access?).

Or we need some smart defaults (maybe have Administrator selected by default for all of these). And if someone deselects all options, that feature is now no longer accessible to anyone.

Otherwise the default state doesn't match user expectations and you also have the state where someone checks Administrator and later unchecks that and they'll assume no one has access but in reality, everyone has access.


printf(
'<div class="ai-alt-text-media-actions" style="margin-top: 16px;">' .
'<button id="ai-alt-text-generate-button" class="button button-secondary" type="button" data-attachment-id="%1$d">%2$s</button>' .

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.

Any reason for these spacing changes? I'm assuming just some automated linting but would keep diffs cleaner to revert that

* {@inheritDoc}
*/
public function register(): void {
if ( ! \WordPress\AI\current_user_can_access_feature( $this->get_id() ) ) {

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.

Wondering if there's a way we could implement this in the Abstract_Feature class instead of having to duplicate this in every individual feature class? Just seems like a lot of duplicated code that could maybe be handled differently

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.

As a practical example here, there are a number of features that never call current_user_can_access_feature right now, even though we show those settings in the UI.

Seems we're missing:

  • Slug generation
  • Type Ahead
  • Content Translation
  • Image Generation

* {@inheritDoc}
*/
public function register(): void {
if ( ! \WordPress\AI\current_user_can_access_feature( $this->get_id() ) ) {

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.

If we do keep this here, we should follow the approach of doing

use function WordPress\AI\current_user_can_access_feature;

So this can then be simplified to just current_user_can_access_feature()

}

/**
* Registers experiment infrastructure.

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.

Suggested change
* Registers experiment infrastructure.
* {@inheritDoc}

* @since 1.2.0
*/
public function register(): void {
$this->register_infrastructure();

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.

Is this needed? Doesn't seem we override that here

}, [ effectiveUsers, selectedUserMap ] );

// Seed selectedUserMap with users returned from the API (capped at
// MAX_USERS i.e. 10 at a time). If more than

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.

So there may be a bug here if someone actually wants to set more than 10 users. I haven't verified directly but looking at the code, it seems anything set over 10 will be dropped on save

@Infinite-Null Infinite-Null Oct 1, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Hi @dkotter,

While saving >10 users works initially in the same session, on a subsequent page refresh the endpoint only returns the first 10 users. Any users beyond the cap fall back to displaying their raw ID:

Screenshot 2026-10-01 at 1 35 00 PM

Note for example I have caped MAX_USERS to 2

Because handleUsersChange matches tokens by display name (mapLabel === label), those raw ID tokens evaluate to undefined and would indeed be dropped if the user edits the field.

To fix this properly, I think we can update /ai/v1/roles-users to accept an include parameter (e.g. ?include=1,2,42) so it always queries and returns the display names for all currently saved user IDs in addition to the top search suggestions.

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.

I think this approach sounds okay. One other thing to consider here is because we map based on display name, there is potential that two users have the same display name and this could allow the wrong user access (I haven't tested that but I think WordPress allows duplicate display names). We may want to map based on user ID

}

$users = array();
$wp_users = get_users( $get_users_args );

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.

We have a few restrictions in place on contributor and subscriber roles but we don't apply that here, meaning I can select a user that is a subscriber even though that role is rejected in other places. I think the user query here needs to remove those users

Comment thread includes/helpers.php Outdated
Comment on lines +977 to +979
if ( array_intersect( $current_user->roles, array( 'subscriber', 'contributor' ) ) ) {
return false;
}

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.

What if a site wants to add subscribers or contributors and give them access? At the moment, that's not possible. Wondering if we need to rework this function so all of our return values pass through the wpai_user_has_role_access filter?

* @since x.x.x
*/
protected function register_infrastructure(): void {
$this->register_post_meta();

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.

I think enqueue_block_assets needs to go here as well as that loads front-end assets


register_setting(
self::OPTION_GROUP,
"wpai_feature_{$feature_id}_users",

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.

This setting will be included in our settings export. That may be fine but this means if someone exports from one site to another and they've given specific users access, the user ID's are likely not the same on the site they are importing too and could result in them giving access to users they don't intend to. May want to consider not exporting this data

@jeffpaul jeffpaul mentioned this pull request Sep 29, 2026
21 of 24 tasks
@jeffpaul

jeffpaul commented Oct 2, 2026

Copy link
Copy Markdown
Member

@Infinite-Null some merge conflicts to resolve here, thanks!

@Infinite-Null

Copy link
Copy Markdown
Contributor Author

I’ve addressed all the feedback and resolved the merge conflicts. A few points for review:

class Title_Generation extends Abstract_Feature {
    // Abstract_Feature gates this automatically based on access controls.
    protected function register_feature(): void {
        add_action( 'wp_abilities_api_init', array( $this, 'register_abilities' ) );
        add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) );
    }
}

For hooks that need to run for all visitors regardless of role (like frontend meta tags or public block styles), features can optionally implement register_infrastructure().

cc: @jeffpaul @dkotter

@Infinite-Null
Infinite-Null requested a review from dkotter October 5, 2026 06:28
@jeffpaul jeffpaul modified the milestones: 1.4.0, 1.5.0 Oct 5, 2026
Comment thread includes/Experiments/Alt_Text_Generation/Alt_Text_Generation.php Outdated
Comment thread includes/helpers.php
Comment thread includes/helpers.php Outdated
@dkotter

dkotter commented Oct 5, 2026

Copy link
Copy Markdown
Contributor

We updated the default behavior so that initially all eligible roles are checked by default. When an admin unchecks a role, that role loses access, and if all roles are unchecked, access is completely removed for everyone

The second part of this is how I'd expect it to work so that's good.

For the first, should all eligible roles be checked or just admins? I don't feel strongly either way, just wondering if we want our defaults to be a bit more strict

@jeffpaul

jeffpaul commented Oct 6, 2026

Copy link
Copy Markdown
Member

For the first, should all eligible roles be checked or just admins? I don't feel strongly either way, just wondering if we want our defaults to be a bit more strict

Currently I think many experiments work for folks with edit posts capability, so that's admins and editors. So perhaps we default to that to keep things simple?

@Infinite-Null

Copy link
Copy Markdown
Contributor Author

Thanks for the feedback! I’ve addressed all the comments and updated the PR accordingly. Could you please take another look when you get a chance?

cc: @jeffpaul @dkotter

@Infinite-Null
Infinite-Null requested a review from dkotter October 12, 2026 11:08
Comment thread includes/helpers.php
$has_access = true;
} elseif ( in_array( $current_user->ID, array_map( 'intval', $users ), true ) ) {
$has_access = true;
} elseif ( array_intersect( $current_user->roles, array( 'subscriber', 'contributor' ) ) ) {

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.

Apologies as I know I had a similar comment on this code already but I think maybe this line should run last. So if a specific user has been given access or a specific role has been given access we don't unintentionally lock someone out, say if a user happens to have two roles on a site (say editor and contributor)

public function register(): void {
$this->register_infrastructure();

if ( ! $this->current_user_can_access() ) {

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.

Sorry for missing this in earlier reviews but we may need to rethink how this access control is run in regards to our custom abilities.

This approach works fine to prevent the UI from being available but if someone wants to access Abilities via the REST API (say for example, via an MCP client), I think this will lock them out as this code runs prior to the REST request from being set up properly so there isn't a proper user to verify.

If that is the case, we'll need to reconsider how we handle access checks, at least for abilities, maybe moving those into the ability permission_callbacks

Comment thread includes/helpers.php
* @return string[] Array of default role slugs.
*/
function get_default_feature_roles(): array {
$default_candidates = array( 'administrator', 'editor' );

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.

I know we've gone back and forth on this a bit so apologies if things are getting lost in the mix. What I'd recommend is by default, if someone hasn't set any custom user access settings, we should default to what we have today, which is generally handled in our permission_callbacks (typically those that have edit_posts).

If someone turns on user access controls, the defaults we show them should be admin and editor and if they then save that default, that's when we use those values. So if access controls haven't been turned on or if they've been turned on but nothing has been saved, access should remain the same as it is today. This prevents any backward breaking changes here.

Comment thread includes/helpers.php
Comment on lines +1008 to +1009
$roles = get_option( "wpai_feature_{$feature_id}_roles", null );
$users = get_option( "wpai_feature_{$feature_id}_users", null );

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.

We should likely preload these values otherwise we're making lots of extra DB queries on all pages. See #1119 for details

* @since 0.6.0
*/
abstract public function register(): void;
public function register(): void {

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.

Another thought here that may not matter but we previously expected any child class to override this method. If someone does that now, they completely bypass our user checks. What if someone has built custom experiments on top of the plugin and are overriding this method? Wondering if we should attempt to keep backwards compat here?

/**
* {@inheritDoc}
*
* @since 0.9.0

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.

Since this is a new method now, we should update this to x.x.x. Will need to do this in all experiments that have that set

Comment thread includes/helpers.php
*
* @since x.x.x
*
* @return string[] Array of default role slugs.

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.

Suggested change
* @return string[] Array of default role slugs.
* @return list<string> Array of default role slugs.

* @since x.x.x
*/
protected function register_infrastructure(): void {
// Moderate new comments automatically in the background.

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.

Technically it's not in the background, it's on a page reload

Suggested change
// Moderate new comments automatically in the background.
// Moderate new comments.

return true;
}

return \WordPress\AI\current_user_can_access_feature( static::get_id() );

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.

To match what we do elsewhere, I'd suggest we add a use statement to the top of the file for:

use function WordPress\AI\current_user_can_access_feature;

And then change this to just:

current_user_can_access_feature( static::get_id() );

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.

Expose role/user access controls per feature/experiment

4 participants