Repository navigation
Feat/add role user access controls - #749
Infinite-Null wants to merge 72 commits into
Conversation
…trolSettings to enable explicit saving
… update dependencies
…ion in AccessControlSettings
…ction tracking in AccessControlSettings
… and refresh lockfile
…pdate clear hook type definition
|
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 If you're merging code through a pull request on GitHub, copy and paste the following into the bottom of the merge commit message. To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook. |
…ication logic in helper function
…ic to reset access control settings upon toggle
…rom suggestions in AccessControlSettings
…me variables for consistent styling
Codecov Report❌ Patch coverage is 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
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
@Infinite-Null FYI some merge conflicts to resolve to keep this moving along |
|
The failing tests seem to be unrelated to the changes introduced in this PR. |
dkotter
left a comment
There was a problem hiding this comment.
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>' . |
There was a problem hiding this comment.
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() ) ) { |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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() ) ) { |
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
| * Registers experiment infrastructure. | |
| * {@inheritDoc} |
| * @since 1.2.0 | ||
| */ | ||
| public function register(): void { | ||
| $this->register_infrastructure(); |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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:
Note for example I have caped
MAX_USERSto 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.
There was a problem hiding this comment.
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 ); |
There was a problem hiding this comment.
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
| if ( array_intersect( $current_user->roles, array( 'subscriber', 'contributor' ) ) ) { | ||
| return false; | ||
| } |
There was a problem hiding this comment.
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(); |
There was a problem hiding this comment.
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", |
There was a problem hiding this comment.
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
…up and refine feature access checks and user queries
|
@Infinite-Null some merge conflicts to resolve here, thanks! |
…d fix user role in controller test
|
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 |
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 |
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? |
…and add WP-CLI support
| $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' ) ) ) { |
There was a problem hiding this comment.
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() ) { |
There was a problem hiding this comment.
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
| * @return string[] Array of default role slugs. | ||
| */ | ||
| function get_default_feature_roles(): array { | ||
| $default_candidates = array( 'administrator', 'editor' ); |
There was a problem hiding this comment.
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.
| $roles = get_option( "wpai_feature_{$feature_id}_roles", null ); | ||
| $users = get_option( "wpai_feature_{$feature_id}_users", null ); |
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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
| * | ||
| * @since x.x.x | ||
| * | ||
| * @return string[] Array of default role slugs. |
There was a problem hiding this comment.
| * @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. |
There was a problem hiding this comment.
Technically it's not in the background, it's on a page reload
| // Moderate new comments automatically in the background. | |
| // Moderate new comments. |
| return true; | ||
| } | ||
|
|
||
| return \WordPress\AI\current_user_can_access_feature( static::get_id() ); |
There was a problem hiding this comment.
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() );
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
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