Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,7 @@ that drives the whole of it against a real WordPress is `tests/unit/Scenario/`.
| `src/Registry/` | `Registrar` (holds registered `Sub_Plugin` objects), `Reader` (the registration buffer, drained into the registrar on the way past; the object every pass reads the registry through), `Contracts\Registrar_Interface`. |
| `src/Activator.php` | Runs a sub-plugin's activation callback once ever, recorded in one option. |
| `src/Conflict/` | `Detector` (whether a standalone is in the way), `Resolver` (which policy branch to take), `Gatekeeper` (which requests, and which users, may have one resolved), `Redirector` (where the user lands afterwards), `Rewriter` (rewrites the activation-error screen for a registered standalone), `Contracts\Resolver_Interface`. |
| `src/Traits/` | `Guards_Hook_Prefix` (a missing prefix warns and stands down rather than throwing). Cross-cutting only: a trait used by one folder lives in that folder. |
| `src/Traits/` | `Guards_Hook_Prefix` (a missing prefix warns and stands down rather than throwing), `Guards_Plugin_Capability` (which capability a plugin act asks for, shared by the conflict gate and the notice queue). Cross-cutting only: a trait used by one folder lives in that folder. |
| `src/Notices/` | `Writer` (what a notice says, stored under `slug:type` — `merge`, `conflict`, `stranding`, `dependency`), `Presenter` (who may consume it, render-then-clear), `Store` (keeps it), `Renderer` (draws it, `notice-error` for `dependency` and `notice-warning` for the rest), `Contracts\Writer_Interface`. |
| `src/Contracts/`, `src/Exceptions/` | `Provider_Interface`, `Activator_Interface`, `Config_Exception`. |

Expand Down
5 changes: 3 additions & 2 deletions docs/conflict-handling.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,9 @@ multisite and `activate_plugins` otherwise, since deactivating a standalone is n
a network exists. The check matters because `plugins_loaded` fires well before `auth_redirect()`, so
an unauthenticated GET of an admin URL reaches this code on its way to the login screen.

These gates apply whatever the policy is — the non-destructive policies only queue a notice, and a
notice is neither shown nor cleared for a user without the same capability.
These gates apply whatever the policy is — the non-destructive policies only queue a notice, and the
[notice queue](notices.md#who-sees-them) asks for that same capability by name before it shows a
notice or clears one, so nothing a policy queued is consumed by someone the gate would have refused.

The deactivation itself is silent, and covers both scopes on multisite. Silent because the
standalone's own deactivation hook would otherwise run this early: a routine `flush_rewrite_rules()`
Expand Down
29 changes: 17 additions & 12 deletions docs/notices.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,21 @@ that is raised exactly once and never re-queued.

## Who sees them

Rendering prints the queue and then clears it, gated on the `activate_plugins` capability. Since
rendering consumes the queue, a user who cannot act on a notice must not be shown one — a
subscriber loading their profile page would otherwise silently swallow the only warning an
administrator was ever going to get.

On multisite that is usually a network administrator rather than the site administrator who
installed the plugin: core maps `activate_plugins` through `manage_network_plugins` unless the
network has enabled the plugins menu for individual sites. Conflict resolution does not rely on that
mapping and asks for `manage_network_plugins` by name — see
[conflict handling](conflict-handling.md#when-resolution-runs).
Rendering prints the queue and then clears it, gated on the capability
[conflict resolution](conflict-handling.md#when-resolution-runs) asks for: `manage_network_plugins`
on multisite and `activate_plugins` otherwise. Since rendering consumes the queue, a user who cannot
act on a notice must not be shown one — a subscriber loading their profile page would otherwise
silently swallow the only warning an administrator was ever going to get.

On multisite that means a network administrator rather than the site administrator who installed the
plugin, and the network capability is asked for by name rather than left to core's mapping of
`activate_plugins`, which only widens into it while the network keeps the Plugins menu off for
individual sites. On a network that has turned that menu on, every site administrator holds
`activate_plugins` outright: any one of them opening any admin screen would otherwise print a notice
raised for the network and clear it network-wide, and the queue is one option shared by every site,
so it would be gone for everyone else. That covers the dependency notice too — a site administrator
no longer consumes one, which is no loss, since a queue shared by the whole network was never theirs
alone to consume.

## One message, two places

Expand Down Expand Up @@ -56,8 +61,8 @@ use Nexcess\PluginAbsorber\Absorber;
add_action( 'admin_init', function () {
// Gates the read, not just the delete: `admin_init` fires for every logged-in user, and
// draining the queue for one who cannot act on it destroys the only warning an
// administrator was going to get.
if ( ! current_user_can( 'activate_plugins' ) ) {
// administrator was going to get. The same capability the built-in rendering asks for.
if ( ! current_user_can( is_multisite() ? 'manage_network_plugins' : 'activate_plugins' ) ) {
return;
}

Expand Down
20 changes: 9 additions & 11 deletions src/Conflict/Gatekeeper.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
namespace Nexcess\PluginAbsorber\Conflict;

use Nexcess\PluginAbsorber\Traits\Guards_Hook_Prefix;
use Nexcess\PluginAbsorber\Traits\Guards_Plugin_Capability;

/**
* Whether this request may have a conflict resolved at all.
Expand All @@ -31,6 +32,7 @@
*/
class Gatekeeper {
use Guards_Hook_Prefix;
use Guards_Plugin_Capability;

/**
* Admin scripts that exist only to perform work.
Expand Down Expand Up @@ -103,22 +105,18 @@ public function request_may_resolve(): bool {
* unauthenticated GET of any admin URL gets this far. Without this check a stranger could turn
* the standalone off site-wide by requesting a page they are about to be bounced off.
*
* The capability asked for matches what resolution can do, which is why the two differ. The
* deactivation is network-wide: Deactivator leaves deactivate_plugins()'s $network_wide
* at its default, and core reads that as both scopes, so the standalone comes out of the
* network's active plugins whichever site the request arrived on. That is authority a single
* site's administrator does not hold, and asking for activate_plugins would not establish it --
* core only widens that capability into the network one while a network setting says to, so on a
* network that has said otherwise every subsite administrator would pass. Where a network exists,
* the network-scoped capability is the one whose reach matches the action's.
* Which capability that is belongs to Traits\Guards_Plugin_Capability, because the notice
* presenter has to ask the identical question: rendering the queue clears it for everybody, so a
* user who may not have a conflict resolved may not consume the report of one either. Spelling
* the capability here as well is what let the two answers drift apart.
*
* Here rather than inside the default resolver, because it is the one thing about conflict
* resolution that must survive a host binding its own: whoever cannot activate a plugin must not
* be able to deactivate one, and a replacement that forgot to re-check would reopen exactly that.
*
* It gates every policy, not only the destructive one, and that costs nothing. The other
* policies queue a notice, and Notices\Presenter::render() will not render -- or clear -- for a user
* with no plugin capability at all. Queuing on a request that cannot act only parks the notice
* policies queue a notice, and Notices\Presenter::render() will not render -- or clear -- for a
* user this same guard turns away. Queuing on a request that cannot act only parks the notice
* until an administrator who can act arrives, which is the request this gate lets resolution run
* on anyway. Nothing is consumed or suppressed by waiting: the standalone is still there to
* detect.
Expand All @@ -128,7 +126,7 @@ public function request_may_resolve(): bool {
* @return bool
*/
public function user_may_resolve(): bool {
return current_user_can( is_multisite() ? 'manage_network_plugins' : 'activate_plugins' );
return self::user_may_manage_plugins();
}

/**
Expand Down
26 changes: 12 additions & 14 deletions src/Notices/Presenter.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
namespace Nexcess\PluginAbsorber\Notices;

use Nexcess\PluginAbsorber\Exceptions\Config_Exception;
use Nexcess\PluginAbsorber\Traits\Guards_Plugin_Capability;

/**
* Who may consume the queue, and what happens when they do.
Expand All @@ -18,7 +19,11 @@
*
* The capability check lives here rather than in `Renderer` because it guards the clearing as much as
* the drawing: the two have to be decided together, or a user who may not see the queue could still
* destroy it.
* destroy it. Which capability is `Traits\Guards_Plugin_Capability`'s answer, shared with the gate
* that decides who may have a conflict resolved at all — the conflict notices report what that gate
* let happen, so consuming one is the same authority as causing it. The dependency notice is queued
* off the load path, behind no gate at all, and is held to the same capability for the reason above:
* whoever consumes the queue consumes all of it.
*
* The queue is single-consumer. Rendering consumes it for everybody, so the first eligible
* administrator to load any admin screen is the only person who ever sees a given notice —
Expand All @@ -32,18 +37,7 @@
* @since 1.0.0
*/
class Presenter {
/**
* Capability required to see, and thereby consume, the queue.
*
* Rendering clears the queue, so a user who cannot act on a notice must not be shown one:
* a subscriber loading their profile page would otherwise silently swallow the only warning
* an administrator was ever going to get.
*
* @since 1.0.0
*
* @var string
*/
private const CAPABILITY = 'activate_plugins';
use Guards_Plugin_Capability;

/**
* @since 1.0.0
Expand Down Expand Up @@ -84,7 +78,11 @@ public function __construct( Store $store, Renderer $renderer ) {
* @return void
*/
public function render(): void {
if ( ! current_user_can( self::CAPABILITY ) ) {
// Rendering clears the queue, so a user who cannot act on a notice must not be shown one: a
// subscriber loading their profile page would otherwise silently swallow the only warning an
// administrator was ever going to get, and on multisite the queue they swallowed it out of is
// shared by every site on the network.
if ( ! self::user_may_manage_plugins() ) {
return;
}

Expand Down
48 changes: 48 additions & 0 deletions src/Traits/Guards_Plugin_Capability.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<?php
/**
* @package Nexcess\PluginAbsorber
*/

declare( strict_types=1 );

namespace Nexcess\PluginAbsorber\Traits;

/**
* The one answer to "may this user act on a plugin, at the reach this library's acts have".
*
* Two places ask it about the same act. `Conflict\Gatekeeper` asks before a standalone is
* deactivated, and `Notices\Presenter` asks before the queue reporting that deactivation is printed
* — which also clears it, for everybody. So the two have to agree by construction: a consume gate
* looser than the resolve gate hands the only copy of a notice to somebody who could not have caused
* what it reports and cannot undo it.
*
* The capability is network-scoped wherever a network exists, because that is how far the act
* reaches. `Plugin\Deactivator` leaves `deactivate_plugins()`'s `$network_wide` at its default, which
* core reads as both scopes, so the standalone comes out of the network's active plugins whichever
* site the request arrived on; and the notice queue is one network option, so consuming it consumes
* it for every site. `activate_plugins` does not establish that authority and cannot be relied on to
* imply it: core widens it into the network capability only while the `menu_items` site option keeps
* the Plugins menu off, so on a network that has turned that menu on, every site administrator holds
* the site capability outright and none of them holds the network one.
*
* A trait rather than a shared collaborator, for the same reason `Guards_Hook_Prefix` is one: the
* answer comes from `current_user_can()` either way, and all that is shared is which capability to
* name. A collaborator would want a container binding, a constructor argument on both classes and an
* interface nothing dispatches on, to carry one boolean. What it may not be is a literal in each
* class with a docblock in each pointing at the other — that is what the two had, and they drifted
* apart without a single test noticing.
*
* @since 1.0.0
*/
trait Guards_Plugin_Capability {
/**
* Whether the current user may manage plugins at the scope this library acts on.
*
* @since 1.0.0
*
* @return bool
*/
private static function user_may_manage_plugins(): bool {
return current_user_can( is_multisite() ? 'manage_network_plugins' : 'activate_plugins' );
}
}
13 changes: 7 additions & 6 deletions tests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,18 +127,19 @@ class-compile time. The suite does not fail, it fails to start.

## Users and capabilities

Two of the library's gates turn on `activate_plugins`, so a test that reaches
Two of the library's gates turn on the same capability — `manage_network_plugins`
on multisite and `activate_plugins` everywhere else — so a test that reaches
either needs a user who has it. `WithUsers` owns both halves:

```php
$this->become_plugin_administrator(); // someone who may resolve a conflict
$this->create_user( 'subscriber' ); // someone who may not
```

`become_plugin_administrator()` is not just `create_user( 'administrator' )`. On
multisite `activate_plugins` maps through `manage_network_plugins`, which a site
administrator does not have — so it grants super admin there and sets the current
user either way. A test about *that* difference creates the administrator itself.
`become_plugin_administrator()` is not just `create_user( 'administrator' )`. Both
gates name `manage_network_plugins` on multisite, which a site administrator does
not have — so it grants super admin there and sets the current user either way. A
test about *that* difference creates the administrator itself.

## Stubbing functions

Expand Down Expand Up @@ -353,7 +354,7 @@ None of it means anything unless all four hold, and setUp establishes all four:
`set_request_method( 'GET' )`. `Conflict\Gatekeeper` turns away anything else,
so without both of these every policy scenario would pass while resolving
nothing at all.
- **A user who can `activate_plugins`** — `become_plugin_administrator()`. The
- **A user who may manage plugins** — `become_plugin_administrator()`. The
gatekeeper checks the capability before anything is resolved, and the queue
checks the same one before it renders, so as nobody the suite would be
asserting that a no-op is a no-op.
Expand Down
Loading
Loading