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
64 changes: 64 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,69 @@
# Changelog

## 2.0.0

**BREAKING.** Each consumer plugin now owns its own database table, object-cache group, and REST route prefix. Previously every consumer wrote into the single shared `{prefix}wpb_access_control` table — two plugins on the same WordPress install collided on storage, cache, and routes.

### Required code changes for v1.x → 2.0

1. **Add a slug to the manager constructor.** Both args are now required:

```php
// Before
$manager = new AccessControlManager( 'my_plugin_access_control_providers' );

// After (slug must match ^[a-z0-9_]{1,32}$)
$manager = new AccessControlManager(
'my_plugin_access_control_providers',
'my_plugin'
);
```

2. **Add `pluginSlug` to `wpbAcConfig`** (or pass it as a prop when importing the component directly):

```php
wp_localize_script( 'wpb-ac-ui', 'wpbAcConfig', [
'pluginSlug' => 'my_plugin', // NEW: must match the PHP slug
// …existing fields…
] );
```

3. **REST URLs gain a slug segment.** Every endpoint is now under `/wpb-ac/v1/{slug}/...`. cURL / api-fetch callers must update their paths:

```diff
- GET /wpb-ac/v1/rules/{namespace}/{key}
+ GET /wpb-ac/v1/{slug}/rules/{namespace}/{key}
- GET /wpb-ac/v1/providers
+ GET /wpb-ac/v1/{slug}/providers
- GET /wpb-ac/v1/users?search=...
+ GET /wpb-ac/v1/{slug}/users?search=...
- DELETE /wpb-ac/v1/namespaces/{namespace}
+ DELETE /wpb-ac/v1/{slug}/namespaces/{namespace}
```

4. **Optional one-off data migration.** Existing rows stay in `{prefix}wpb_access_control` until each consumer copies them out:

```sql
INSERT INTO {prefix}my_plugin_access_control
SELECT * FROM {prefix}wpb_access_control
WHERE namespace IN ( 'your-namespace-1', 'your-namespace-2' );
```

The library does NOT auto-migrate — it doesn't know which consumer owns which namespace.

### Why

Plugins embedding the library via Composer were silently sharing one table and one REST surface. Two plugins on the same site would collide on rules, on cached payloads (single `wpb_access_control` cache group), and on registered REST routes (last-loaded-wins).

### Changes

- feat(db): per-consumer table — `{prefix}{slug}_access_control` driven by the new required `$table_slug` constructor argument
- feat(db): per-consumer object-cache group (`wpb_ac_{slug}`) and transient prefix
- feat(db): per-slug `db_version_key` option (`wpb_ac_{slug}_db_version`)
- feat(rest): slug-scoped routes under `/wpb-ac/v1/{slug}/...`
- feat(ui): React component requires a new `pluginSlug` prop; `wpbAcConfig.pluginSlug` is required by the auto-render path
- feat(slug): new `WPBoilerplate\AccessControl\Slug` helper validates `^[a-z0-9_]{1,32}$` and throws `\InvalidArgumentException` on invalid input — applied consistently across `RuleTable`, `RuleQuery`, and `AccessControlManager`

## 1.6.0

**BREAKING (BuddyBoss / MemberPress providers):** the `BuddyBossProfileTypeProvider` and `MemberPressMembershipProvider` are now opt-in. Each provider's `is_available()` consults a new filter that defaults to `false`, so the provider is hidden from the React dropdown and denies on every check until the consumer plugin explicitly opts in. Existing rules saved against either provider deny by default after upgrading until the corresponding filter is hooked.
Expand Down
153 changes: 125 additions & 28 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,26 +83,37 @@ require_once __DIR__ . '/vendor/autoload_packages.php';
### 1. Boot the manager

Declare `$manager` at **file scope** (outside any closure) so every subsequent
hook can capture it via `use`. Always pass a **plugin-specific filter tag** to
prevent your providers bleeding into other plugins that also use this library.
hook can capture it via `use`. Pass two arguments:

1. A **plugin-specific provider filter tag** so your providers don't bleed
into other plugins using the library.
2. A **per-plugin table slug** (must match `^[a-z0-9_]{1,32}$`) so your
database table, object-cache group, and REST routes are isolated from
every other plugin embedding the library.

```php
use WPBoilerplate\AccessControl\AccessControlManager;

// File scope — available to all hooks below via `use ( $manager )`.
$manager = new AccessControlManager( 'my_plugin_access_control_providers' );
$manager = new AccessControlManager(
'my_plugin_access_control_providers', // provider filter tag
'my_plugin' // table slug (required)
);
```

`AccessControlManager` owns a `RuleQuery` internally. Instantiating it
registers `RuleTable` via BerlinDB, which creates or upgrades the
`{prefix}wpb_access_control` table automatically on `admin_init`.
`{prefix}my_plugin_access_control` table automatically on `admin_init`.

> **Need to wait for other plugins first?** Use a reference capture instead:
>
> ```php
> $manager = null;
> add_action( 'plugins_loaded', function () use ( &$manager ) {
> $manager = new AccessControlManager( 'my_plugin_access_control_providers' );
> $manager = new AccessControlManager(
> 'my_plugin_access_control_providers',
> 'my_plugin'
> );
> } );
> // All subsequent hooks must also use `&$manager`.
> ```
Expand Down Expand Up @@ -138,7 +149,10 @@ use WPBoilerplate\AccessControl\AccessControlManager;
require_once __DIR__ . '/vendor/autoload_packages.php';

// 2. Create the manager at file scope — captured by all hooks via `use ( $manager )`.
$manager = new AccessControlManager( 'my_plugin_access_control_providers' );
$manager = new AccessControlManager(
'my_plugin_access_control_providers', // provider filter tag
'my_plugin' // table slug (required)
);

// 3. Expose the REST API.
add_action( 'rest_api_init', function () use ( $manager ) {
Expand Down Expand Up @@ -190,6 +204,7 @@ add_action( 'admin_enqueue_scripts', function ( string $hook ) use ( &$settings_

// Pass config to the component via window.wpbAcConfig.
wp_localize_script( 'wpb-ac-ui', 'wpbAcConfig', [
'pluginSlug' => 'my_plugin', // required — must match the PHP table slug
'namespace' => 'my-plugin',
'resourceKey' => 'settings-page',
'restApiRoot' => get_rest_url(),
Expand Down Expand Up @@ -373,6 +388,7 @@ add_action( 'admin_enqueue_scripts', function ( string $hook ) use ( $page_hook

// Pass configuration to the component via window.wpbAcConfig.
wp_localize_script( 'wpb-ac-ui', 'wpbAcConfig', [
'pluginSlug' => 'my_plugin', // required — must match the PHP table slug
'namespace' => 'my-namespace',
'resourceKey' => 'my-resource',
'restApiRoot' => get_rest_url(),
Expand Down Expand Up @@ -400,7 +416,8 @@ add_action( 'my_plugin_settings_page', function () {

| Prop | Type | Required | Default | Description |
|------|------|----------|---------|-------------|
| `namespace` | `string` | ✅ | — | Access-control namespace, e.g. `"mcp"` |
| `pluginSlug` | `string` | ✅ | — | Consumer slug — must match the PHP `table_slug`. Used to build every REST URL (`/wpb-ac/v1/{pluginSlug}/...`). |
| `namespace` | `string` | ✅ | — | Resource namespace, e.g. `"mcp"` |
| `resourceKey` | `string` | ✅ | — | Resource key within the namespace |
| `restApiRoot` | `string` | ✅ | — | WP REST API root URL (`get_rest_url()`) |
| `nonce` | `string` | ✅ | — | `wp_create_nonce('wp_rest')` |
Expand All @@ -424,6 +441,7 @@ apiFetch.use( apiFetch.createNonceMiddleware( wpbAcConfig.nonce ) );
import { createRoot } from '@wordpress/element';
createRoot( document.getElementById( 'my-ac-panel' ) ).render(
<AccessControl
pluginSlug="my_plugin"
namespace="my-namespace"
resourceKey="my-resource"
restApiRoot={ wpbAcConfig.restApiRoot }
Expand Down Expand Up @@ -482,7 +500,9 @@ $rule = $manager->get_query()->get_rule( 'my-namespace', 'my-resource' );

## REST API

REST namespace: **`wpb-ac/v1`**
REST namespace: **`wpb-ac/v1`**. Every route is scoped under the consumer's
table slug — `{slug}` in the paths below is the same string you pass to
`new AccessControlManager(...)`.

All endpoints require `manage_options` (administrator) by default.
Use the `wpb_access_control_rest_permission` filter to override.
Expand All @@ -491,16 +511,16 @@ Use the `wpb_access_control_rest_permission` filter to override.

| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/rules/{namespace}/{key}` | Read the current rule |
| `PUT` | `/rules/{namespace}/{key}` | Create or replace a rule |
| `DELETE` | `/rules/{namespace}/{key}` | Clear a rule (revert to unrestricted) |
| `DELETE` | `/namespaces/{namespace}` | Purge all rules for a namespace |
| `GET` | `/providers` | List registered providers and their options |
| `GET` | `/users?search=...&limit=10` | Search WordPress users |
| `GET` | `/{slug}/rules/{namespace}/{key}` | Read the current rule |
| `PUT` | `/{slug}/rules/{namespace}/{key}` | Create or replace a rule |
| `DELETE` | `/{slug}/rules/{namespace}/{key}` | Clear a rule (revert to unrestricted) |
| `DELETE` | `/{slug}/namespaces/{namespace}` | Purge all rules for a namespace |
| `GET` | `/{slug}/providers` | List registered providers and their options |
| `GET` | `/{slug}/users?search=...&limit=10` | Search WordPress users |

> **Slashes in namespace**: The `{namespace}` URL segment cannot contain
> literal slashes — encode them as `%2F`:
> `.../rules/procureco%2Fv1/my-key`.
> `.../my_plugin/rules/procureco%2Fv1/my-key`.
> The `{key}` segment allows literal slashes.

### Request / response shapes
Expand Down Expand Up @@ -570,43 +590,43 @@ Authorization: Basic base64(username:application_password)
#### cURL

```bash
# Read
# Read (replace 'my_plugin' with your slug throughout)
curl -H "X-WP-Nonce: <nonce>" \
https://example.com/wp-json/wpb-ac/v1/rules/my-namespace/my-resource
https://example.com/wp-json/wpb-ac/v1/my_plugin/rules/my-namespace/my-resource

# Set
curl -X PUT \
-H "X-WP-Nonce: <nonce>" \
-H "Content-Type: application/json" \
-d '{"ac_key":"wp_role","ac_options":["editor","author"]}' \
https://example.com/wp-json/wpb-ac/v1/rules/my-namespace/my-resource
https://example.com/wp-json/wpb-ac/v1/my_plugin/rules/my-namespace/my-resource

# Namespace with slashes
curl -X PUT \
-H "X-WP-Nonce: <nonce>" \
-H "Content-Type: application/json" \
-d '{"ac_key":"wp_role","ac_options":["editor"]}' \
https://example.com/wp-json/wpb-ac/v1/rules/procureco%2Fv1/endpoints%2Flist
https://example.com/wp-json/wpb-ac/v1/my_plugin/rules/procureco%2Fv1/endpoints%2Flist

# Clear
curl -X DELETE \
-H "X-WP-Nonce: <nonce>" \
https://example.com/wp-json/wpb-ac/v1/rules/my-namespace/my-resource
https://example.com/wp-json/wpb-ac/v1/my_plugin/rules/my-namespace/my-resource
```

#### PHP (`wp_remote_request`)

```php
// Read
$response = wp_remote_get(
rest_url( 'wpb-ac/v1/rules/my-namespace/my-resource' ),
rest_url( 'wpb-ac/v1/my_plugin/rules/my-namespace/my-resource' ),
[ 'headers' => [ 'X-WP-Nonce' => wp_create_nonce( 'wp_rest' ) ] ]
);
$rule = json_decode( wp_remote_retrieve_body( $response ), true );

// Set
wp_remote_request(
rest_url( 'wpb-ac/v1/rules/my-namespace/my-resource' ),
rest_url( 'wpb-ac/v1/my_plugin/rules/my-namespace/my-resource' ),
[
'method' => 'PUT',
'headers' => [
Expand All @@ -623,28 +643,31 @@ wp_remote_request(
```js
import apiFetch from '@wordpress/api-fetch';

const slug = 'my_plugin'; // must match the PHP table slug

// Read
const rule = await apiFetch( { path: '/wpb-ac/v1/rules/my-namespace/my-resource' } );
const rule = await apiFetch( { path: `/wpb-ac/v1/${slug}/rules/my-namespace/my-resource` } );

// Set
await apiFetch( {
path: '/wpb-ac/v1/rules/my-namespace/my-resource',
path: `/wpb-ac/v1/${slug}/rules/my-namespace/my-resource`,
method: 'PUT',
data: { ac_key: 'wp_role', ac_options: [ 'editor', 'author' ] },
} );

// Search users (for the wp_user provider UI)
const users = await apiFetch( { path: '/wpb-ac/v1/users?search=jane&limit=10' } );
const users = await apiFetch( { path: `/wpb-ac/v1/${slug}/users?search=jane&limit=10` } );

// List providers (for building a custom UI)
const providers = await apiFetch( { path: '/wpb-ac/v1/providers' } );
const providers = await apiFetch( { path: `/wpb-ac/v1/${slug}/providers` } );
```

#### Vanilla `fetch`

```js
const nonce = document.querySelector( 'meta[name="wp-rest-nonce"]' )?.content;
const apiUrl = '/wp-json/wpb-ac/v1';
const slug = 'my_plugin';
const apiUrl = `/wp-json/wpb-ac/v1/${slug}`;

// Read
const rule = await fetch( `${apiUrl}/rules/my-namespace/my-resource`, {
Expand Down Expand Up @@ -922,7 +945,13 @@ the consuming plugin.

## Database Table Reference

Table: `{prefix}wpb_access_control` · DB layer: BerlinDB `^2.0` · Schema version: `202605120001`
Table: `{prefix}{slug}_access_control` — one per consumer (e.g.
`wp_mcp_access_control`, `wp_abilities_access_control`).
DB layer: BerlinDB `^3.0` · Schema version: `202605120001`

Each consumer's table is created on the first `admin_init` after the
manager is instantiated. The schema is identical across consumers; only
the name and the `wpb_ac_{slug}_db_version` option differ.

| Column | Type | Notes |
|--------|------|-------|
Expand All @@ -944,3 +973,71 @@ Indexes: `PRIMARY KEY (id)` · `UNIQUE (namespace, key(191), access_control_valu
| `everyone` | One row: `access_control_key='everyone'`, `access_control_value=''` |
| `wp_role` + `['editor','author']` | Two rows, both `access_control_key='wp_role'`; values `'editor'`, `'author'` |
| `wp_user` + `['1','42']` | Two rows, both `access_control_key='wp_user'`; values `'1'`, `'42'` |

---

## Upgrading from 1.x

v2.0.0 introduces a required `$table_slug` constructor argument. Each
consumer plugin now owns its own table, object-cache group, and REST
route prefix — fixing the silent collision when two plugins embed the
library on the same WordPress install.

### 1. Update the manager constructor

```diff
- $manager = new AccessControlManager( 'my_plugin_access_control_providers' );
+ $manager = new AccessControlManager(
+ 'my_plugin_access_control_providers',
+ 'my_plugin' // table slug: ^[a-z0-9_]{1,32}$
+ );
```

Invalid slugs throw `\InvalidArgumentException` immediately.

### 2. Update `wpbAcConfig` / React props

```diff
wp_localize_script( 'wpb-ac-ui', 'wpbAcConfig', [
+ 'pluginSlug' => 'my_plugin',
'namespace' => 'my-namespace',
'resourceKey' => 'my-resource',
// …
] );
```

### 3. Update REST URLs

Every endpoint moves under `/wpb-ac/v1/{slug}/...`:

```diff
- GET /wpb-ac/v1/rules/{namespace}/{key}
+ GET /wpb-ac/v1/{slug}/rules/{namespace}/{key}

- GET /wpb-ac/v1/providers
+ GET /wpb-ac/v1/{slug}/providers

- GET /wpb-ac/v1/users?search=...
+ GET /wpb-ac/v1/{slug}/users?search=...

- DELETE /wpb-ac/v1/namespaces/{namespace}
+ DELETE /wpb-ac/v1/{slug}/namespaces/{namespace}
```

### 4. (Optional) Migrate existing rows

The library **does not** auto-migrate from `{prefix}wpb_access_control`.
Run a one-off SQL copy from your plugin's update routine, filtering by
the namespaces *your* plugin owns:

```sql
INSERT INTO {prefix}my_plugin_access_control
( namespace, `key`, access_control_key, access_control_value, created_at, updated_at )
SELECT
namespace, `key`, access_control_key, access_control_value, created_at, updated_at
FROM {prefix}wpb_access_control
WHERE namespace IN ( 'your-namespace-1', 'your-namespace-2' );
```

Drop the legacy table only after **every** consumer plugin on the site
has upgraded — otherwise an un-upgraded plugin will lose its rules.
2 changes: 1 addition & 1 deletion assets/build/index.asset.php
Original file line number Diff line number Diff line change
@@ -1 +1 @@
<?php return array('dependencies' => array('react-jsx-runtime', 'wp-api-fetch', 'wp-element'), 'version' => 'dd14e0948119b5b0ee35');
<?php return array('dependencies' => array('react-jsx-runtime', 'wp-api-fetch', 'wp-element'), 'version' => '61d2200a81dc17d89e02');
Loading
Loading