0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

WordPressのCPT権限を管理画面とREST APIで確かめる

0
Posted at

「下書きは入力担当が書き、公開は別の担当者が行う」。この要件を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 には、readedit_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";

実行結果の usersposts に出た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やプラグインを更新した後にも、同じ条件で確かめ直せます。

参考資料

顧客へ任せる操作や承認担当から整理したい場合は、運営する2GUの「WordPress採用サイトの権限設計|顧客に渡す操作の決め方」に一般向けの判断手順をまとめています。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?