「下書きは入力担当が書き、公開は別の担当者が行う」。この要件をWordPressに実装するとき、公開ボタンが見えないことだけでは確認が足りません。すでに公開した投稿の本文を更新できるかも、別に確かめる必要があります。
ここでは、商品や顧客サイトから切り離したカスタム投稿タイプ(CPT)で試します。入力担当には、自分の下書きの作成・編集と、レビュー待ちへの変更を許可します。管理画面での保存、REST APIの応答、データベースに保存された結果を照合しました。
この例では、公開権限に加えて、公開済み投稿の編集権限も外す必要がありました。公開権限がなくても、公開済み投稿を編集できれば、公開中の本文を変更できたためです。
最初に、誰の何を変更できるかを決める
架空の入力担当A・Bと、公開担当の3アカウントを用意します。AとBは同じ権限です。Aで確認する範囲を、次のように決めました。
| 操作 | 入力担当A |
|---|---|
| 自分の下書きを作成・編集する | 許可 |
| 自分の投稿をレビュー待ちにする | 許可 |
| Bの下書きを編集する・自分の投稿をBへ割り当て直す | 拒否 |
| 自分の下書きを公開・予約公開する | 拒否 |
| 自分やBの公開済み投稿の本文を変える | 拒否 |
| 投稿をゴミ箱へ移す・完全に削除する | 拒否 |
| 通常の投稿・固定ページ・サイトタイトルを変更する | 今回の代表操作では拒否 |
公開担当には、このCPTの他人の投稿を編集して公開する権限を与えます。これは「公開済み記事の修正案を承認待ちで保存する」仕組みではありません。入力担当による公開済み投稿の編集自体を止める例です。
破棄できる単一サイトを用意する
確認した環境は、WordPress 7.1、PHP 8.5.10、Twenty Twenty-Five 1.5です。有効なプラグインは、後述の試験用プラグイン1本だけです。2026年9月4日に、架空の投稿とアカウントで実行しました。
ローカルURLは http://127.0.0.1:8907 に固定し、WordPressの環境種別を local にしています。共有の検証サイトや本番へ入れないでください。以下の固定パスワードも、使い捨てのローカル試験専用です。
CPTの権限名と、ロールへの付与を分ける
次を cpt-permissions-proof.php としてプラグイン用ディレクトリへ置き、新規サイトで有効化します。既存の管理者・編集者ロールは変更しません。
<?php
/**
* Plugin Name: CPT Permissions Proof
* Description: Disposable local-only capability example. Not a 2GU product feature.
* Version: 1.0.0
*/
defined( 'ABSPATH' ) || exit;
function cpt_proof_register_type() {
register_post_type( 'proof_note', array(
'label' => 'Proof notes',
'public' => true,
'show_in_rest' => true,
'rest_base' => 'proof-notes',
'supports' => array( 'title', 'editor', 'author' ),
'capability_type' => array( 'proof_note', 'proof_notes' ),
'map_meta_cap' => true,
) );
}
add_action( 'init', 'cpt_proof_register_type' );
register_activation_hook( __FILE__, function () {
if ( wp_get_environment_type() !== 'local' || is_multisite() ) {
wp_die( 'Use a disposable local single site only.' );
}
if ( get_role( 'proof_writer' ) || get_role( 'proof_publisher' ) ) {
wp_die( 'Proof role collision: no roles changed.' );
}
add_role( 'proof_writer', 'Proof writer', array(
'read' => true,
'edit_proof_notes' => true,
) );
add_role( 'proof_publisher', 'Proof publisher', array(
'read' => true,
'edit_proof_notes' => true,
'edit_others_proof_notes' => true,
'edit_published_proof_notes' => true,
'publish_proof_notes' => true,
) );
} );
capability_type は、この投稿タイプで使う権限名の元を指定します。map_meta_cap を有効にすると、WordPressは対象投稿の所有者や状態に応じて権限を判定します。権限名を登録しただけでは、利用者に権限を与えたことにはなりません。
入力担当の proof_writer には、read と edit_proof_notes だけを付けています。この設定では、作成に使う create_posts も、既定で edit_proof_notes に対応します。既存投稿の編集だけを許可し、新規作成を禁止する要件なら、この例のままでは足りません。
ロール名が入力担当かどうかを調べるだけでは、投稿ごとの違いを扱えません。対象IDを渡す current_user_can( 'edit_post', $post_id ) で確かめます。今回の結果は、自分の下書きだけがtrueでした。Bの下書きと、自分・Bの公開済み投稿はfalseでした。
初期データを作る
次は、実証に使った初期データ作成部分です。上のプラグインを有効化した後、WordPressを読み込んだPHPの実行環境で一度だけ実行します。通常のPHPファイルとして単独実行するコードではありません。
たとえば、コードをWeb公開ディレクトリの外へ保存し、対象のローカルWordPressでWP-CLIの wp eval-file に保存先を渡す方法があります。今回の実測では、コンテナ内のPHPからWordPressを読み込んで実行しました。
架空のアカウントと投稿を作るコード
<?php
if ( wp_get_environment_type() !== 'local' || home_url() !== 'http://127.0.0.1:8907' || is_multisite() ) {
throw new RuntimeException( 'Unexpected proof environment.' );
}
function proof_assert( $condition, $message ) {
if ( ! $condition ) throw new RuntimeException( $message );
}
proof_assert( ! get_option( 'cpt_proof_fixture' ), 'Fixture collision.' );
foreach ( array( 'proof_a', 'proof_b', 'proof_publisher' ) as $name ) {
proof_assert( ! username_exists( $name ), 'User collision.' );
}
$f = array( 'users' => array(), 'posts' => array(), 'extraPosts' => array() );
foreach ( array( 'a' => 'proof_a', 'b' => 'proof_b', 'publisher' => 'proof_publisher' ) as $key => $name ) {
$id = wp_insert_user( array( 'user_login' => $name, 'user_pass' => 'local-proof-only', 'user_email' => $name . '@example.invalid', 'role' => $key === 'publisher' ? 'proof_publisher' : 'proof_writer' ) );
proof_assert( ! is_wp_error( $id ), 'User creation failed.' );
$f['users'][ $key ] = $id;
update_option( 'cpt_proof_fixture', $f, false );
}
foreach ( array( 'a_draft' => array( 'a', 'draft' ), 'b_draft' => array( 'b', 'draft' ), 'a_published' => array( 'a', 'publish' ), 'b_published' => array( 'b', 'publish' ) ) as $key => $spec ) {
$id = wp_insert_post( array( 'post_type' => 'proof_note', 'post_status' => $spec[1], 'post_author' => $f['users'][ $spec[0] ], 'post_title' => 'Proof ' . $key, 'post_content' => 'Fixture body ' . $key ), true );
proof_assert( ! is_wp_error( $id ), 'Post creation failed.' );
$f['posts'][ $key ] = $id;
update_option( 'cpt_proof_fixture', $f, false );
}
foreach ( array( 'post', 'page' ) as $type ) {
$f['posts'][ $type ] = wp_insert_post( array( 'post_type' => $type, 'post_status' => 'draft', 'post_title' => 'Proof unrelated ' . $type, 'post_content' => 'Unrelated fixture', 'post_author' => $f['users']['a'] ), true );
}
$f['baselineStandardRoles'] = hash( 'sha256', serialize( array( get_role( 'administrator' )->capabilities, get_role( 'editor' )->capabilities ) ) );
update_option( 'cpt_proof_fixture', $f, false );
echo wp_json_encode( array( 'ok' => true, 'users' => $f['users'], 'posts' => $f['posts'], 'wordpress' => get_bloginfo( 'version' ), 'php' => PHP_VERSION, 'theme' => wp_get_theme()->get( 'Name' ), 'themeVersion' => wp_get_theme()->get( 'Version' ), 'activePlugins' => get_option( 'active_plugins' ), 'coreSourceHashes' => array( 'capabilities.php' => hash_file( 'sha256', ABSPATH . WPINC . '/capabilities.php' ), 'class-wp-rest-posts-controller.php' => hash_file( 'sha256', ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-posts-controller.php' ) ) ) ) . "\n";
実行結果の users と posts に出たIDを控えます。以後のURLやAPIでは、その環境のIDを使います。別サイトで同じ番号が同じ投稿を指すとは限りません。
投稿者はA・B、状態は下書き・公開済みの4通りです。通常の投稿と固定ページも各1件作り、このCPT以外へ権限が広がっていないかを確認します。
管理画面では保存と再読み込みまで確かめる
Aでログインし、自分の下書きの編集画面を開きます。タイトルと本文を変更して「保存」し、画面を開き直して値が残っていることを確認します。新規作成画面から下書きを保存できることも確認します。
今回のブラウザー自動検証では、ブロックエディターのデータストアにタイトル・本文を設定し、可視の保存ボタンを押しました。その後、保存結果と再読み込み後の値を照合しています。キーボードによる全入力操作を試した、という意味ではありません。
次に、Bの下書きと、自分・Bの公開済み投稿の編集URLへ直接アクセスします。今回の環境では、いずれもHTTP 403でした。通常の投稿と固定ページの編集URLも403でした。
メニューを隠していても、直接アクセスを拒否できるとは限りません。API経由の更新も、続けて調べます。
REST APIでは応答と保存前後を対応させる
Aの許可された編集画面を開いた状態で、開発者ツールのコンソールに次の関数を定義します。ログインCookieと、そのアカウントのREST用nonceを使います。nonceやCookieをコピーして記事・ログへ残す必要はありません。
async function proofRequest(route, data, method = 'POST', invalidNonce = false) {
const nonce = window.wpApiSettings?.nonce || window.wp?.apiFetch?.nonceMiddleware?.nonce;
if (!nonce) throw new Error('Open an allowed WordPress editor first.');
const response = await fetch('/index.php?rest_route=' + encodeURIComponent(route), {
method,
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': invalidNonce ? 'invalid-local-fixture-nonce' : nonce },
...(method === 'GET' ? {} : { body: JSON.stringify(data) }),
});
const body = await response.json();
return { status: response.status, code: body.code ?? null, id: body.id ?? null };
}
APIのパスは、たとえば自分の下書きなら /wp/v2/proof-notes/対象ID です。関数の第2引数に更新内容、第3引数にHTTPメソッドを渡します。省略時のメソッドはPOSTです。
| リクエスト | 第2引数の例 | 今回の応答 |
|---|---|---|
| 自分の下書きの本文を変更 | { content: 'HTTP own draft edit' } |
200 |
| 自分の投稿をレビュー待ちにする | { status: 'pending' } |
200 |
| Bの下書きの本文を変更 | { content: 'MUST NOT SAVE' } |
403 / rest_cannot_edit
|
| 自分の投稿の投稿者をBへ変更 | { author: BのユーザーID } |
403 / rest_cannot_edit_others
|
| 自分の投稿を公開 | { status: 'publish' } |
403 / rest_cannot_publish
|
| 自分の投稿を予約公開 | { status: 'future', date: '2030-01-01T12:00:00' } |
403 / rest_cannot_publish
|
| 自分・Bの公開済み本文を変更 | 本文だけ/本文と status: 'publish' の両方 |
いずれも403 / rest_cannot_edit
|
| 自分の投稿をゴミ箱へ移す/完全削除 | DELETEで { force: false } / { force: true }
|
いずれも403 / rest_cannot_delete
|
新規作成はIDのない /wp/v2/proof-notes へ送ります。タイトル・本文・status: 'draft' を指定したリクエストは201で、Aの下書きとして保存されました。
通常の投稿、固定ページ、設定の代表操作も拒否されました。公開担当でAの投稿を公開するリクエストは200で、保存状態が publish になりました。
拒否されたときは、保存内容が変わっていないことも確かめます。今回のHTTP検証では、拒否されるリクエストの前後で、試験投稿のID、投稿者、タイトル、本文、状態、日時を比較しました。サイトタイトルと入力担当の権限も比較し、変わっていないことを確認しています。
認証の失敗と権限不足を分ける
この環境では、ログインCookieのない更新リクエストは401でした。ログイン済みでも不正なnonceを送ると、403 / rest_cookie_invalid_nonce でした。
一方、Aが有効なCookie・nonceでBの投稿を編集した場合は、403 / rest_cannot_edit です。同じ403でも、試したい権限チェックまで到達していない場合があります。statusとエラーコードの両方を記録します。
WordPress内部の rest_do_request() による実証も行いましたが、これはCookie・nonceを含むHTTP認証の試験とは分けています。内部REST処理は21ケース、管理画面とHTTP経由は31ケースでした。この件数は確認した操作の数であり、全経路の安全性を示す値ではありません。
公開済み編集の権限だけを戻すとどうなるか
隔離環境で、入力担当ロールに edit_published_proof_notes だけを一時追加しました。publish_proof_notes は付けていません。
すると、Aの公開済み投稿に対して、次の両方が200になりました。
- 本文だけを送り、
statusを送らない更新 - 本文と、現在と同じ
status: 'publish'を送る更新
本文は実際に保存され、未ログインの公開ページにも変更後の内容が表示されました。
WordPress 7.1のREST投稿コントローラーでは、受け取った状態が現在の状態と違う場合に、状態変更の検査へ進みます。同じ publish を明示する更新を、新しく公開する操作と同一には扱えません。詳細は prepare_item_for_database() と、実測版のCoreソースで確認しました。
入力担当による公開済み本文の変更も止めたいなら、公開権限だけでなく、公開済み投稿の編集権限も外します。
一時追加した権限を外すと、直接の編集画面は再び403になりました。有効な認証でのREST更新も403 / rest_cannot_edit となり、拒否前後の保存内容は変わりませんでした。
片付けと、案件で確認し直す範囲
試験用ロールはデータベースへ保存されます。プラグインの停止やPHPコードの変更だけで、付与済みの権限が消えたとは扱わないでください。
今回は作成元を記録した試験投稿・アカウント・ロールを片付け、残存がないことを確認しました。管理者・編集者の権限が変わっていないことも確認し、最後に専用コンテナを破棄しています。既存ユーザーに試験ロールを付ける方法や、既存サイトのロールを一括削除する方法は使っていません。
実案件では、対象のWordPress版、追加プラグイン、投稿種別、役割、操作経路に合わせて同じ表を試します。今回の対象外は、マルチサイト、アップロード、タクソノミー、フィールド単位の制限、独自RESTエンドポイント、外部認証です。
公開済み投稿の修正を「いったん提案として保持し、承認後に反映する」要件も別設計です。編集そのものを拒否する今回のロールでは満たせません。
引き渡し時には、ロール名だけでなく、「誰が・誰の投稿を・どの状態で・どの経路から変更したか」と保存結果を残します。その表があれば、WordPressやプラグインを更新した後にも、同じ条件で確かめ直せます。
参考資料
- register_post_type()
- get_post_type_capabilities()
- current_user_can()
- Roles and Capabilities
- WP-CLI eval-file
顧客へ任せる操作や承認担当から整理したい場合は、運営する2GUの「WordPress採用サイトの権限設計|顧客に渡す操作の決め方」に一般向けの判断手順をまとめています。