Skip to content

[4.4] Web サーバー権限に依存しないコンテンツ管理 — CLI 導線と build/cache 分離の実装設計 #7072

Description

@nanasess

概要(Overview)

EC-CUBE は現状、リポジトリのほぼ全体に Web サーバーの書き込み権限があることを前提に動作します。
このため、公式ドキュメント(https://doc4.ec-cube.net/permission)に沿ってパーミッションを厳格化すると、
管理画面の多くの機能が動作しなくなります。

書き込み権限の要求範囲は Eccube\Controller\Install\InstallController に定義されています。

// src/Eccube/Controller/Install/InstallController.php:94-109
protected array $eccubeDirs = [
    'app/Plugin', 'app/PluginData', 'app/proxy', 'app/template', 'html', 'var', 'vendor',
];
protected array $eccubeFiles = ['composer.json', 'composer.lock'];

加えて InstallController.php:197 でプロジェクトルート自体の書き込み可否を、:209Finder
上記ディレクトリ配下の全ファイルを再帰的に検査します。

一方、書き込み系機能を無効化するスイッチ ECCUBE_RESTRICT_FILE_UPLOAD は既に存在します
app/config/eccube/packages/eccube.yaml:171-181RestrictFileUploadListener)。
しかし有効にすると対象機能が 403 になるだけで代替手段がないため、実運用では選択しにくい状態です。

本 issue では、これらの書き込みを CLI(SSH ログインユーザー権限)へ移すことで、
Web サーバーには最小限の書き込み権限しか与えずに運用できる状態を目指す実装設計を提案します。

関連して #5069 では、Web サーバーに書き込み権限がない場合に FTP/SSH のアカウントを管理画面から入力させる
方式(WordPress の FS_METHOD)が提案されています。本 issue はこれに対する別解として、
認証情報を Web アプリケーション側に一切渡さず、CLI 実行時のプロセス権限をそのまま使う方式を提案するものです。

期待する内容(Expect) or 要望(Requirement)

1. 書き込み先を 3 レーンに分類する

現在の書き込み箇所を「Web サーバー権限が必須かどうか」で分類します。

レーン W — Web サーバー所有(書き込み必須・CLI へ移せない)

リクエスト処理中に書き込みが発生するもの。

パス 該当箇所
var/runtime/{env}, var/log Kernel.phpgetRuntimeDir() / getLogDir()
var/sessions/{env} framework.yaml:13Web サーバー専用。CLI から書く必要がないため 0700 とし、他ユーザーからの一覧・読み取りも禁止する
html/upload/save_image, html/upload/temp_image ProductController.php:312,578
html/upload/refund_request/{save,temp} RefundRequestService.php:61,76,127(フロントの会員が投稿するため)
メンテナンスファイル SystemService.php:144,170ECCUBE_MAINTENANCE_FILE_PATH。既定がプロジェクトルート直下の場合は var/ 配下への変更を検討)

レーン W であっても、CLI から書き込む必要はありません。デプロイやバッチでレーン W を触る操作は
sudo -u www-data bin/console ... のように Web サーバーのユーザーとして実行します。

レーン S — SSH ユーザー所有(Web サーバーは読み取りのみ)

パス 現在の書き込み箇所 CLI 代替
app/template/** PageController.php:151, BlockController.php:143, MailController.php:108,116 eccube:page:* / eccube:block:* / eccube:mail-template:*(Phase 3a #7105
html/user_data/** CssController.php:54, JsController.php:55, FileController.php:203,334 eccube:asset:* / eccube:user-data:*(Phase 3b #7114
app/Plugin, app/PluginData, app/proxy, html/plugin, vendor PluginService, EntityProxyService.php:87,94 既存 eccube:plugin:*
composer.json / composer.lock ComposerApiService 既存 eccube:composer:*
.env SecurityController.php:108,123, TemplateController.php:95 eccube:env:*(Phase 3b #7114
app/keystore/** FilesystemKeyStore.php:59-77UcpMessageSigner / AcpMessageSigner が実行時に生成) eccube:keystore:*(Phase 3c #7117
var/build/{env}, var/cache/{env} コンパイル済みコンテナ・ルーティング・メタデータ・prod の twig eccube:cache:build

プラグイン管理は既に CLI が揃っており、コンテンツ系と .env は Phase 3a / 3b、鍵は Phase 3c で整備しました。
レーン S に未整備の導線は残っていません(テンプレートのアップロードは本 issue のスコープ外)。
これらを一括で入出力する eccube:contents:export / import は Phase 5(#7121)で整備しました。

注意が必要な箇所

  • app/PluginData — プラグインが実行時にデータを書き込む用途があるため、プラグインによっては
    Web サーバー権限が必要になります。ドキュメント上で注記が必要です。

  • app/proxy/entitySchemaService.php:79-84 がプラグインのスキーマ更新時に ReloadSafeAttributeDriver
    生成し、一時プロキシを書き出します(ReloadSafeAttributeDriver.php:150)。通常のリクエストでは書き込みませんが、
    管理画面からプラグインを操作するとリクエスト処理中の書き込みになります。プラグイン操作を CLI に寄せることが、
    このディレクトリを読み取り専用にする前提条件になります。

  • app/keystore — 秘密鍵の格納先です。CLI で事前に配置し、Web サーバーからは読み取りのみとする
    (レーン S)
    とします。秘密鍵はデプロイ成果物であり、Web サーバーから書き込めると
    漏洩だけでなく署名鍵の差し替えを許すためです。FilesystemKeyStore::write()
    FilesystemKeyStore.php:59-77)は mkdir(0700)chmod(0600) で作成者専用にするため、
    Web サーバーが実行時に生成した鍵は CLI から読めず、その逆も成立しません。
    鍵保管ディレクトリ自体は 共通の鍵保管ディレクトリ app/keystore/ の導入 (各種暗号鍵・シークレットの標準置き場) #6797(PR feat(agent-commerce): エージェントコマース共通基盤 Phase 1a (#6777) #6802)で導入済みです。

    鍵のパーミッションは Phase 3c で見直しました。 Web サーバーは UcpProfileBuilder.php:82
    公開鍵 JWK を組み立てるために秘密鍵の読み取りが必須で、所有者専用(0700 / 0600)のままだと
    CLI が配置した鍵を読めません。chgrp を実行できない環境(CLI ユーザーが www-data グループに
    属していない構成、共有レンタルサーバー)があるため、既定を 0755 / 0644 へ変更しました。
    HTTP からの到達は app/keystore/.htaccessdeny from all)が担保しており、これはモードを
    変えても変わりません(Web サーバー自身は読める必要があるため)。緩和で増えるのはローカル読み取りのみで、
    .env が DB 資格情報を 0644 で持つ現状と同水準です。所有者専用に戻す場合は
    ECCUBE_KEYSTORE_STRICT_PERMISSIONS=1 を設定します(読み取りを許す手当ては運用側で行い、
    eccube:keystore:generate が読めない状態を検出してエラーにします)。

    ただしPhase 3c 以前は実行時生成の経路しかありませんでした/.well-known/ucpUcpDiscoveryController.php:47)は
    認証不要の公開ルートで、最初のアクセスで UcpMessageSigner::getPrivateKey()
    UcpMessageSigner.php:118-129)が EC P-256 を生成して keyStore->write() します
    AcpMessageSigner.php:129 の HMAC シークレットも同様)。分離した構成では Web サーバーが
    app/keystore へ書けないため、鍵を事前配置する CLI が要ります(Phase 3c [4.4] eccube:keystore:* で署名鍵を CLI から事前配置できるようにする (#7072 Phase 3c) #7117)。
    分離モードで実測したところ、鍵を置かずに /.well-known/ucp を叩くと
    鍵格納ディレクトリ ... を作成できません500 になり、eccube:keystore:generate
    事前配置すると 200 になることを確認しました。
    なお実行時の自動生成は残します。CLI を使えない共有レンタルサーバーでも成立させるための
    フォールバックであり、共通の鍵保管ディレクトリ app/keystore/ の導入 (各種暗号鍵・シークレットの標準置き場) #6797 / ユニバーサル コマース プロトコル(UCP)の対応 #6574 で決めた解決順(環境変数によるパス上書き → 既定ファイル)の
    一部です。単一ユーザーで運用する構成ではレーン分離自体が不要なため、両立します。

2. コンテンツ操作の CLI コマンド群

ページ・ブロック・メールテンプレートは、DB レコードと twig ファイルが対になっています
PageController.phppersist/flush の直後に dumpFile を実行)。
このため scpgit pull でファイルを配布するだけでは機能せず、両者を一体で扱う CLI が必要です。

eccube:page:list|show|apply|remove           dtb_page + app/template/user_data/*.twig
eccube:block:list|show|apply|remove          dtb_block + app/template/{theme}/Block/*.twig
eccube:mail-template:list|show|apply|remove  dtb_mail_template + app/template/{theme}/Mail/*.twig
eccube:asset:show|apply                      html/user_data/assets/{css,js}/customize.*
eccube:user-data:list|show|put|remove        html/user_data/**
eccube:env:get|set                           .env(EnvFileService を再利用)
eccube:contents:export|import                上記の DB 側の定義を app/contents/*.yaml で一括入出力

設計原則

  • apply(upsert)を基本形にするnew/edit に分けず冪等にする。同じ入力を複数回適用しても結果が同じになる
  • 標準入力に対応する--body=- で stdin から読み込む。
    cat page.twig | ssh host 'cd /path && bin/console eccube:page:apply --route=guide --body=-'
    のようにリモートへ一時ファイルを置かずに適用できる
  • --dry-run で差分を表示する
  • --format=json と意味のある終了コード — CI・エージェントから扱えるようにする
  • showapply の逆操作にするeccube:page:show --route=guide > guide.twig で取り出せる

管理画面がコアテンプレートの写しを作らないようにする

twig の探索は app/template/{theme}src/Eccube/Resource/template/default より優先します
app/config/eccube/packages/twig.yamlpaths)。一方 *ContentService::save()
無条件に dumpFile() していました(PageContentService.php:207 ほか)。管理画面のページ保存も
同じ経路(PageController.php:133)を通るため、コアページのメタ情報(ページ名・meta_tags
レイアウト紐付け)を 1 つ変えるだけで app/template/{theme}/ に内容が同じ写しができ、以後
src/Eccube/Resource/template/default/ への upstream マージ(脆弱性パッチを含む)が画面へ
反映されなくなる
状態でした。

本文が現在の内容(ファイルが無ければコアのテンプレート)と同じ場合は書き出さないようにします。
比較は FormType の trim(既定で有効)に合わせて正規化します。揃えないとコアのテンプレートは
末尾の改行だけで差分と判定され、毎回写しを書き出してしまいます。

併せて、本文を指定しない新規登録は配置先に既にあるテンプレートを初期値にします。
従来は空文字列が初期値になり NotBlank で弾かれ、リポジトリへコミット済みのテンプレートに
対応するレコードを作れませんでした。

UserDataFileService::write() は既に「内容が同じなら書かない」実装で、残る 3 サービスを
それに揃えた形です。

Git 運用のための一括入出力

テンプレートはファイル、コンテンツ定義は DB に分かれており、Git に残せるのは前者だけです。
eccube:contents:export / import は、この**残らない側(DB)**だけを yaml で入出力します。

app/contents/
  manifest.yaml   pages.yaml   blocks.yaml   mail_templates.yaml   layouts.yaml
bin/console eccube:contents:export                     # 既定は app/contents/ へ
bin/console eccube:contents:import --dry-run           # 差分だけ表示
bin/console eccube:contents:import
bin/console eccube:contents:import --prune --dry-run   # 削除対象の確認

テンプレートの本文はアーカイブへ複製しません。 EC-CUBE のカスタマイズ運用では
src/Eccube/Resource/template/** を直接カスタマイズし、脆弱性パッチとバージョンアップを
git merge で取り込むのが一般的で、テンプレートは既にリポジトリで管理されています。
そこへ写しを持つと二重管理になり、merge で解決できなくなります。

app/template/user_data/foo.twig をコミットして pages.yaml に 1 行足せば、
import がそのページを作ります。テンプレートの内容は書き換えません。

論点 判断
レイアウトの参照 名前で参照します。dtb_layout.idIDENTITY 採番で環境ごとに変わるためです。layout_name に一意制約が無いので、名前が一意でない場合は export でエラーにします(当てずっぽうに解決すると、取り込み先で別のレイアウトへ静かに貼り替わります)
ブロックの配置場所 dtb_block_position.sectionLayout::TARGET_ID_* の名前(header / side_left …)へ変換して持ちます。差分から配置が読めるようにするためです
取り込みの鍵 アーカイブのファイル名ではなく yaml の中身から取り、FormType と同じ正規表現で検証します。配置先の外を指す値をサービスへ渡しません
--prune 既定は無効です。削除できるのはユーザーが作成したページ(EDIT_TYPE_USER)・削除可能なブロック / メールテンプレート・どのページからも参照されていないレイアウト(Layout::isDeletable())のみです
書き込み失敗 実行ユーザーの権限の問題は全件に及ぶため、個別のエラーにせず中断し eccube:doctor:permissions を案内します
html/user_data customize.css / customize.js 以外は .gitignore で意図的に除外されている(アップロード物)ため既定では扱いません。リポジトリ丸ごと管理する構成では --include=user_data を指定します
出力の安定性 鍵でソートし、manifest にタイムスタンプを入れません。再エクスポートで差分が出ないようにするためです

本文がアーカイブに無いため、レコードはあるのにテンプレートが見つからない状態は取り込めません。
NotBlank のエラーでは原因が分からないので、配置先のパスを添えて弾きます。

3. var/buildvar/cache の分離(キャッシュ生成の CLI 一本化)

現状、テンプレートのコンパイル結果はリクエスト処理中に Web サーバーが生成します。
権限が不足しているとキャッシュ生成に失敗し、システムエラーになるケースが報告されています。
キャッシュ生成を CLI に一本化したいというのが要望です。

Symfony 7.4 にはこれを実現する機構があり、EC-CUBE はまだ利用していません。

現状(getBuildDir() 未実装)

vendor/symfony/twig-bundle/DependencyInjection/TwigExtension.php:170-177

if (true === $config['cache']) {
    $autoReloadOrDefault = ...->resolveValue($config['auto_reload'] ?? $config['debug']);
    $buildDir = $container->getParameter('kernel.build_dir');
    $cacheDir = $container->getParameter('kernel.cache_dir');
    if ($autoReloadOrDefault || $cacheDir === $buildDir) {
        $config['cache'] = '%kernel.cache_dir%/twig';
    }
}

app/config/eccube/packages/twig.yamlcacheauto_reload も指定していないため、
cache は既定 true、auto_reloaddebug(= %kernel.debug%)にフォールバックします。
さらに src/Eccube/Kernel.phpgetBuildDir() が無いため kernel.build_dir === kernel.cache_dir となり、
条件を無条件に満たして cache = var/cache/{env}/twig になります。

結果として prod では、

  • auto_reload = false のため、既存テンプレートを更新しても再コンパイルされない(キャッシュ削除が必要)
  • 未コンパイルのテンプレートはリクエスト処理中に Web サーバーが書き込む

という状態になっています。

提案

vendor/symfony/twig-bundle/Resources/config/twig.php:86-105 には 3 層のキャッシュが定義されています。

->set('twig.template_cache.runtime_cache', FilesystemCache::class)
    ->args([param('kernel.cache_dir').'/twig'])
->set('twig.template_cache.readonly_cache', ReadOnlyFilesystemCache::class)
    ->args([param('kernel.build_dir').'/twig'])
->set('twig.template_cache.warmup_cache', FilesystemCache::class)
    ->args([param('kernel.build_dir').'/twig'])
->set('twig.template_cache.chain', ChainCache::class)
    ->args([[service('...readonly_cache'), service('...runtime_cache')]])

Kernel::getBuildDir()getCacheDir() と別パスにするだけで上記の条件分岐を通らなくなり、
この 3 層構成(読み取り専用を優先し、無い場合のみ runtime にフォールバック)が有効になります。
TemplateCacheWarmer が build ディレクトリ側へ事前コンパイルします。

devauto_reload = %kernel.debug% = true のため第 1 条件で従来どおり単層のキャッシュを使い、
prod のみが読み取り専用運用に切り替わります(開発体験は変わりません)。
なお runtime_cache が指す %kernel.cache_dir%/twig は、後述の var/runtime 分離により
var/runtime/{env}/twig へ差し替えます。

ディレクトリ 所有者 内容 生成
var/build/{env}(新設) SSH ユーザー
Web は読み取りのみ
コンパイル済みコンテナ、preload、ルーティング、serializer / validator メタデータ、prod の twig eccube:cache:build(CLI)
var/cache/{env} SSH ユーザー
Web は読み取りのみ
翻訳カタログ、HTMLPurifier のシリアライザキャッシュ 同上
var/runtime/{env}(新設) Web サーバー cache pool、twig のフォールバック、CSV の一時領域、MCP セッション、プロファイラ、プラグインの展開先、http_cache リクエスト処理中
var/log, var/sessions/{env} Web サーバー リクエスト処理中

var/runtime を新設する理由

Kernel::buildContainer()vendor/symfony/http-kernel/Kernel.php:615-623)は
build と cache の双方に書き込み権限を要求します。

foreach (['cache' => $this->getCacheDir(), 'build' => $this->warmupDir ?: $this->getBuildDir()] as $name => $dir) {
    if (!is_dir($dir)) { ... } elseif (!is_writable($dir)) { throw new \RuntimeException(...); }
}

つまり var/cache/{env} を Web サーバー所有のままにすると、SSH ユーザーはコンテナを再生成できません。
そこで リクエスト処理中に書き込まれるものを var/runtime/{env} へ集約し、
var/cachevar/build を SSH ユーザー所有にします。移設先はすべて設定で変更できます。

対象 設定
cache.app pool framework.cache.directory(既定が %kernel.share_dir% 起点のため Kernel::getShareDir() で追従)
cache.system pool サービス定義がハードコードのため RuntimeCacheDirPass で差し替え
MCP セッション mcp.http.session.directory
プロファイラ(dev / install) framework.profiler.dsn
twig のランタイムキャッシュ サービス定義がハードコードのため RuntimeCacheDirPass で差し替え
CSV の一時領域 / UCP カタログ / プラグインの展開先 eccube_runtime_dir

実装上の制約: cache:clear の扱い

vendor/symfony/framework-bundle/Command/CacheClearCommand.php

:82   if (!is_writable($realCacheDir)) { throw new RuntimeException('Unable to write in the "%s" directory.'); }
:86   $useBuildDir = $realBuildDir !== $realCacheDir;
:91   if (!is_writable($realBuildDir)) { throw ... }
:96   $fs->rename($realCacheDir, $oldCacheDir);
:100  $fs->mkdir($realCacheDir);

cache:clearbuild / cache 双方の書き込み権限を要求します。3 分割では var/buildvar/cache
どちらもレーン S のため、CLI ユーザーで実行すれば成功します(Web サーバーのユーザーでは
Unable to write in the "var/cache/prod" directory. で正しく失敗します)。
build / cache の 2 分割案ではどちらのユーザーでも失敗しますが、var/runtime を分けた 3 分割では成立します。

ただし分離した構成で cache:clear --no-warmup を使うとサイトが落ちますCacheClearCommand
build ディレクトリも削除する($useBuildDir の分岐)ため、--no-warmup ではコンパイル済みコンテナが
再生成されません。次のリクエストで Kernel::buildContainer() が cache と build の双方に書き込み権限を要求し、
Web サーバーは Unable to write in the "cache" directory500 になります。復旧できるのは
CLI ユーザーの eccube:cache:build だけです。composer install の auto-scripts が無事なのは、
2 本目の cache:warmup --no-optional-warmers がコンテナを再生成するためです。

この経路があるため、cache:clear の終了コードを非ゼロにしてはいけません
symfony/flex の ScriptExecutor::execute() は非ゼロを検出すると
Script cache:clear --no-warmup returned with error code 3composer install を中断させます。
自前のコマンド(eccube:cache:build / eccube:page:apply 等)は auto-scripts から呼ばれないため
終了コード 3 を返して構いません。

したがって次の対応を行います。

  • eccube:cache:build(新規コマンド)cache:clear を使わず build ディレクトリのみを再生成する
    CacheClearCommand と同じ「別名で warmup → rename で差し替え」を build 側だけに適用し、var/runtime には触れない)。
    書き込み権限が不足している場合は終了コード 3eccube:doctor:permissions への案内を返す
  • Eccube\Util\CacheUtil の見直しkernel.terminatecache:clear を、build ディレクトリへ書き込めるかで分岐させる。
    書き込めない場合は実行時キャッシュのみ削除し、eccube:cache:build の実行を案内する。
    clearTwigCache() は build 側も削除する(prod では build 側が読み取り専用キャッシュとして優先されるため、
    runtime だけ消しても管理画面で更新したテンプレートが反映されない)
  • InstallController::$eccubeDirs を 3 レーンに合わせて見直す

自動 warmup の抑制

kernel.build_dirkernel.cache_dir が別パスになると、Kernel::initializeContainer()
コンテナ再構築のたびに enableOptionalWarmers() を呼びます(vendor/symfony/http-kernel/Kernel.php:559-563)。
これが 2 つの問題を起こします。

全テンプレートのコンパイルがデプロイ時以外でも走り、composer install 時のピークメモリが跳ね上がります。
実測値は次のとおりです。

経路 対策前 対策後
composer installcache:warmup --no-optional-warmers 219 MiB 91.5 MiB
eccube:cache:build 169 MiB

テンプレートのコンパイル結果は事前生成が必須ではない(build に無ければ実行時に runtime へ
フォールバックする)ため、twig.template_cache_warmerkernel.cache_warmer タグを外し
BuildDirCacheWarmerPass)、eccube:cache:build が明示的に実行します。

他の warmer は外しません。 とくに HTMLPurifier の SerializerCacheWarmer
シリアライザキャッシュの基底ディレクトリを作る唯一の箇所で、外すと
HTMLPurifier_DefinitionCache_Serializer::_prepareDir()
Base directory ... does not existE_USER_WARNING を出し、debug 環境では
Symfony のエラーハンドラがこれを例外へ変換するためテンプレートの描画が失敗します。

umask の是正

アプリケーションが作成するディレクトリ・ファイルのパーミッションは、実行経路によって次のように異なっていました。

実行経路 umask 生成されるディレクトリ / ファイル
Web(prod) OS 既定(通常 022) 0755 / 0644
Web(dev) index.phpif ($debug) の中で umask(0000) 0777 / 0666
CLI(prod / dev とも) bin/console無条件umask(0000) 0777 / 0666

つまり 本番でも CLI が作成したものだけが world-writable になり、同一サーバーの他ユーザーから
書き換えられます。レーン W を「Web サーバー所有」にしても、アプリケーションが 0777 で作り直すため
分離が成立しません。

umask(0000) を両方から取り除き、環境変数 ECCUBE_UMASK(8 進数表記)で任意に設定できるようにします。
umask はコンテナを生成するより前に決める必要があるため、コンテナのパラメータではなく環境変数から読み込みます
index.php / bin/consoleapply_umask() を呼ぶ。既定値と意図は eccube.yamleccube_umask で宣言)。

  • 未設定(既定)— OS / PHP-FPM の既定 umask に従う
  • ECCUBE_UMASK=0000 — 4.3 以前と同じ挙動(ディレクトリ 0777 / ファイル 0666)に戻す。
    Web サーバーと CLI が別ユーザーで、かつ双方が同じファイルへ書き込む必要がある環境向け
  • mkdir() にリテラルで 0777 を渡していた箇所(PluginService.phpEntityProxyService.php
    LogType.php)は 0755 にする

互換性への影響: 「CLI は使えるが sudo が使えない」環境では、CLI で作成したファイルを Web サーバーから
書き換えられなくなります。該当する操作は Web サーバーのユーザーで実行するか、ECCUBE_UMASK=0000 を設定します。

なお HTMLPurifier は Cache.SerializerPermissions(既定はディレクトリ 0755 / ファイルは & 0666)で明示的に
chmod するため umask の影響を受けません。先に生成したユーザー以外は書き込めないため、var/cache/{env}
CLI と Web で共有する構成では実行ユーザーによって Permission denied が発生していました
DefinitionCache/Serializer.php:203-242)。翻訳カタログとあわせて kernel.cache_dir 側の
ビルド生成物として CLI が生成し、Web サーバーは読み取りのみとする
ことで解消します
Serializer::get()_prepareDir() を呼ばないため、読み取りは読み取り専用でも成立します)。

残る制約

build ディレクトリに存在しないテンプレートは runtime_cachevar/runtime/{env}/twig)へフォールバックして
コンパイルされます。Symfony の設計上これは避けられないため、warmup 漏れの検出
(prod で var/runtime/{env}/twig にファイルが生成されていたら警告)を後述の診断コマンドに含めます。

4. 診断コマンド eccube:doctor:permissions

3 レーンの期待値と実際の所有者・パーミッションを突き合わせ、差分と修正方法を出力します。

判定に is_writable() は使えません。is_writable() が返すのは実行ユーザー(= CLI ユーザー)から見た可否だけで、
Web サーバーから書けるかどうかは分からないためです。所有者 uid・グループ gid・パーミッションビットから推定します。

  • other に w がある → 書き込み可
  • 所有者 uid が一致し owner に w がある → 書き込み可
  • グループ gid が一致し group に w がある → 書き込み可
  • それ以外は書き込み不可(ただし uid 0 は常に可)

Web サーバーの補助グループ・ACL・SELinux までは判定できないため、出力には「推定」であることを明記します。

Web サーバーの実行ユーザー名は環境ごとに異なるため、コード中に固定値を持たせず、
Web サーバーでしか生成されないファイルの fileowner() / filegroup() から実測します。優先順は次のとおりです。

  1. var/sessions/{env}/ 配下 — framework.yaml:13save_pathWeb リクエストでのみ生成されるため最も確実。
    ただし推奨どおり 0700 に絞ると CLI からは一覧できないため、その場合は次の候補へフォールバックする
  2. html/upload/temp_imagehtml/upload/refund_request/{save,temp} 配下 — アップロードでのみ生成される。
    html/upload/save_image は配布画像(no_image_product.pngsand-*.png 等)を含み、その所有者を拾って
    誤判定するため判定には使わない
  3. var/log/{env}/ 配下 — bin/console 実行でも書き込まれるため、1・2 が得られないときの補助材料に留める。
    なお prod のログは %kernel.logs_dir%/%kernel.environment%/site.log(rotating_file)で、
    main ハンドラが fingers_crossedaction_level: error)のため平常時はファイルが生成されません
  4. いずれも見つからない場合は「判定不能」の警告とし、確認方法(ps aux | grep -E 'php-fpm|httpd|apache2' 等)を案内する

ext-posixcomposer.json の require に含まれていないため、posix_getpwuid() が使えない環境では uid を数値で表示します。

出力は既定が人間向けのテーブル、--format=json で機械可読とします。終了コードは
0 = 問題なし / 1 = 要対応の NG あり / 2 = オプション不正(Command::INVALID)です。

[NG] var/build/prod が Web サーバーから書き込み可能です(想定: 読み取りのみ)
       owner = deploy(1000)   perm = 0775   group = www-data(33)

[NG] var/runtime/prod の所有者が Web サーバーの実行ユーザーと一致しません
       var/runtime/prod           owner = deploy(1000)
       var/sessions/prod/sess_*   owner = www-data(33)  ← Web サーバーはこのユーザーで動作しています

既存コマンドへも同じ判定を適用します。現在 PluginCommandTrait.php:41-52 はキャッシュクリアの失敗を
$io->error() で表示するだけで戻り値を持たず、このトレイトを使う 6 コマンド
eccube:plugin:enable / disable / install / uninstall / update / schema-update)は
いずれも clearCache() の直後に return 0 するため、失敗しても成功として扱われます。書き込み不可を検出した場合は本処理を完了させたうえで、
専用の終了コード 3 = 完了したが手動操作が必要と、必要な操作の案内を返すようにします
2 は Symfony の Command::INVALID が使用済みのため避けます)。

あわせて次の 2 点も修正します。

  • PluginCommandTrait.php:46Process は cwd を指定していないため、プロジェクトルート以外から実行すると
    キャッシュクリアが失敗する。kernel.project_dir を cwd に渡す。あわせて既定タイムアウトを無効化する
    Process::__construct() の既定は ?float $timeout = 60。超過すると子プロセスが kill され、キャッシュが
    中途半端に削除された状態で「削除できませんでした」と案内することになる。bin/console を子プロセスで
    実行する箇所は eccube:env:set も同様)
  • PluginInstallCommand.php:47-56--path 経路だけキャッシュクリアを実行していない。
    あわせて到達不能な分岐も整理する(PluginService::install() は常に true を返すか例外を投げるため、
    if ($this->pluginService->install(...)) の false 側は通らない。残しておくと --path 指定時に
    path or code is required. へ落ちるコードに読める)

5. 管理画面を読み取り専用モードに対応させる

  • ECCUBE_RESTRICT_FILE_UPLOAD=1 時に 403 を返すのではなく、内容は表示し保存操作のみ無効化したうえで、
    対応する CLI コマンドを案内する
  • CssController.php:46 / JsController.php:47file_exists($path) && is_writable($path)
    Phase 3b([4.4] html/user_data と .env を CLI から操作できるようにする (#7072 Phase 3b) #7114)で対応済み。読み込みは AssetContentService::read()file_exists のみで判定するため、
    書き込み不可でも現在の内容が表示されます。保存ボタンの無効化と CLI の案内表示は本 Phase に残ります
  • eccube_restrict_file_upload_urlseccube.yaml:172-181)に、同種の書き込みを行う
    admin_setting_shop_mailMailController.php:108,116)が含まれていないため追加を検討します

段階的な実装

Phase のチェックは PR のマージをもって付けます。

  • Phase 1 — 権限診断と既存 CLI の終了コード修正 / 実装完了・レビュー中 Web サーバー権限を診断する eccube:doctor:permissions の追加と、プラグインコマンドの終了コード修正 #7098
    • eccube:doctor:permissions(3 レーンの期待値と実際の所有者・パーミッションの突き合わせ)
    • 書き込み可否の推定(is_writable() は実行ユーザーから見た可否しか返さないため、所有者 uid / グループ gid / パーミッションビットから判定)
    • eccube:plugin:* 6 コマンドの終了コード修正(3 = 完了したが手動操作が必要)
    • 権限を分離した docker 環境(docker-compose.permission-lanes.yml
  • Phase 2var/build / var/cache / var/runtime の 3 分割とキャッシュ生成の CLI 一本化 / 実装完了・レビュー中 feat: キャッシュ生成を CLI へ一本化し, 実行時に書き込むキャッシュを分離する #7100
    • Kernel::getBuildDir()var/runtime の新設(実行時に書き込まれるものを集約)
    • eccube:cache:buildcache:clear が使えない構成でも build のみ再生成)
    • CacheUtil の見直し(build へ書き込めるかで分岐し、書けなければ実行時キャッシュのみ削除)
    • umask(0000) の廃止と ECCUBE_UMASK
    • cache:clear で実行時 cache pool を削除する(RuntimeCachePoolClearer
  • Phase 3 — レーン S へ書き込む導線の CLI 化(eccube:page:* / eccube:block:* / eccube:mail-template:* / eccube:asset:* / eccube:user-data:* / eccube:env:* / eccube:keystore:*
    • Phase 3aeccube:page:* / eccube:block:* / eccube:mail-template:*(DB レコードと twig を対で扱う Service を新設し, 管理画面からも同じ経路を通す)/ 実装完了・レビュー中 feat: ページ・ブロック・メールテンプレートを CLI から操作できるようにする #7105
    • Phase 3beccube:asset:* / eccube:user-data:* / eccube:env:*html/user_data のパス検証を FileController から抽出)/ 実装完了・レビュー中 [4.4] html/user_data と .env を CLI から操作できるようにする (#7072 Phase 3b) #7114
      • eccube:asset:show|applyhtml/user_data/assets/{css,js}/customize.*
      • eccube:user-data:list|show|put|removehtml/user_data/**html/ はドキュメントルート配下のため、管理画面のファイル管理と同じ拡張子許可リスト eccube_file_uploadable_extensions を CLI にも適用する)
      • eccube:env:get|set.env。値は get で単一キーのみ返し、一括ダンプは提供しない)
      • UserDataFileService の新設(FileControllercheckDir() / getUserDataDir() / getJailDir() / アップロード検証を抽出し、管理画面と CLI が同じ検証を通る)
      • パス境界検査の既存バグ 2 件を修正(区切り文字を伴わない前方一致で html/user_data_evil を配下と誤判定する / リンク先が存在しないシンボリックリンクを素通しし配下の外へ書き込める)
      • eccube:env:set はビルド再生成を別プロセスで実行する(CacheBuildCommand は同一プロセスでカーネルを reboot するため、起動時に読み込んだ古い .env を焼き込む)。.env.local.php があるなど反映されない場合は終了コード 3
      • SecurityController / TemplateController.env 書き込みを EnvFileService::set() へ集約し、file_put_contents() の失敗(戻り値 false・短い書き込み)を検出する
      • CssController / JsController の読み取りを書き込み権限から切り離す(Phase 4 から前倒し。下記「5. 管理画面を読み取り専用モードに対応させる」参照)
    • Phase 3ceccube:keystore:*(署名鍵・共有シークレットを CLI で事前配置する。上記「注意が必要な箇所」の app/keystore 参照)/ 実装完了・レビュー中 [4.4] eccube:keystore:* で署名鍵を CLI から事前配置できるようにする (#7072 Phase 3c) #7117
      • 鍵の生成・一覧・確認(eccube:keystore:generate|list|showKeyStoreInterface 経由で purpose 単位に冪等)
      • 既存の鍵を上書きしない(差し替えは --force を指定したときのみ。鍵の有無と読み取り可否を分けて判定し、読めないだけの鍵を未生成とみなして上書きしない)
      • show は鍵素材を表示しない(署名鍵は公開鍵 JWK と kid、共有シークレットはアルゴリズムと長さのみ)
      • 鍵の生成方法を KeyPurposeInterface へ集約し、CLI と実行時の自動生成が同じ経路を通る(プラグインから用途を追加できる seam を兼ねる)
      • 実行時の自動生成は残す(CLI を使えない共有レンタルサーバー向けのフォールバック)
      • 鍵の既定パーミッションを 0755 / 0644 にする(ECCUBE_KEYSTORE_STRICT_PERMISSIONS=10700 / 0600)。作成したディレクトリ階層のモードは umask に依らず明示する
      • 生成後に Web サーバーから鍵を読めるかを判定し、読めなければ終了コード 1(署名と discovery はリクエスト処理中に行われるため、置いただけでは成立しない)
      • eccube:doctor:permissionsapp/keystore をレーン S として NG にする現状の判定はそのままとし、本コマンドを対処手段として案内する
  • Phase 4 — 管理画面の読み取り専用モード、ECCUBE_RESTRICT_FILE_UPLOAD の対象見直し / 実装完了・レビュー中 [4.4] 管理画面を読み取り専用モードに対応させ, ECCUBE_RESTRICT_FILE_UPLOAD の対象漏れを埋める (#7072 Phase 4) #7119
    • CSS 管理 / JS 管理の読み取りを書き込み権限から切り離す(Phase 3b [4.4] html/user_data と .env を CLI から操作できるようにする (#7072 Phase 3b) #7114 で前倒し)
    • ECCUBE_RESTRICT_FILE_UPLOAD=1 で 403 を返すのをやめ、内容は表示し保存操作のみ無効化する(安全なメソッドは通し、書き込みを伴うメソッドだけ 403 にする)
    • メニュー項目を非表示にするのをやめる(TwigInitializeListener::getDisplayEccubeNav()。辿れないと「内容は表示する」が成立しないため)
    • 保存操作を無効化した画面で、対応する CLI コマンドを案内する(eccube_restrict_file_upload_urls をルート名 → CLI コマンドのマップにし、共通バナー @admin/notice_read_only.twig で表示)
    • 保存・削除・有効化のボタンを無効化する(Store/template.twigenvWritable と同じ形。CSS / JS 管理は Ace の構文チェックが prop('disabled', false) で戻すため JS 側にもガードを入れる)
    • ファイル管理はディレクトリ移動も POST のため、FileController 側で create / upload だけを拒否する
    • 制限対象の漏れを埋める。レーン S へ書き込む管理画面ルートを全数監査したところ 27 本あり、従来は 9 本しか対象になっていなかった
      • メールテンプレート(admin_setting_shop_mail / _edit / _delete
      • 削除操作(admin_content_page_delete / admin_content_block_delete / admin_content_file_delete / admin_store_template_delete
      • .env を書く画面(admin_setting_system_security / admin_store_template
      • プラグイン操作(admin_store_plugin_enable / _disable / _uninstall / _update と、オーナーズストア経由の admin_store_plugin_api_* 5 本)
      • 監査した 27 ルートがすべて設定に載っていることをテストで固定する(漏れの再発防止)
    • 認証キー登録(admin_store_authentication_setting)は対象にしない。composer.json を書くが保存先は dtb_base_info で composer 側は副作用であり、塞ぐと認証キーの登録手段が消えるため
  • Phase 5eccube:contents:export / import(Git 運用)/ 実装完了・レビュー中 feat(cli): eccube:contents:export / import でコンテンツ定義を Git 管理できるようにする (#7072 Phase 5) #7121
    • 役割分担を「ファイルは Git、DB は CLI」に定める。アーカイブはテンプレートの本文を持たず、Git に残らない DB 側(dtb_page / dtb_page_layout / dtb_block / dtb_mail_template / dtb_layout / dtb_block_position)だけを扱う
    • *ContentService::save() が無条件に dumpFile() していたのをやめ、本文が現在の内容(ファイルが無ければコアのテンプレート)と同じ場合は書き出さない。管理画面でコアページのメタ情報だけを変更しても app/template に影ができなくなる
      • 比較は FormType の trim(既定で有効)に合わせて正規化する。揃えないとコアのテンプレートは末尾の改行だけで差分と判定され、毎回写しを書き出してしまう
      • PageContentService::readTemplate()@user_data 名前空間へのフォールバックを除去。書き込み先と同じディレクトリを指すため成功し得ず、見つからなかった問い合わせが FilesystemLoader::$errorCache に残って同じプロセスで書き出した直後のテンプレートを読めなくしていた
    • 本文を指定しない新規登録は、配置先に既にあるテンプレートを初期値にする(従来は空文字列が初期値になり NotBlank で弾かれ、コミット済みのテンプレートからレコードを作れなかった)
    • アーカイブの構造(セクションごとに 1 ファイル。鍵でソートし、manifest にタイムスタンプを入れず、再エクスポートで差分が出ないようにする)
    • レイアウトは名前で参照する(dtb_layout.id は環境ごとに変わる)。名前が一意でない場合は export でエラーにし、取り込み時に別のレイアウトへ静かに貼り替わることを防ぐ
    • ブロックの配置場所(dtb_block_position.section)は Layout::TARGET_ID_* を名前へ変換して持つ
    • 取り込みの鍵はアーカイブのファイル名ではなく yaml の中身から取り、FormType と同じ正規表現で検証する
    • --prune(既定は無効)。削除できるのはユーザーが作成したページ・削除可能なブロック / メールテンプレート・どのページからも参照されていないレイアウトのみ
    • 書き込み失敗は実行ユーザーの権限の問題で全件に及ぶため、個別のエラーにせず中断し eccube:doctor:permissions を案内する
    • html/user_datacustomize.css / customize.js 以外が .gitignore で除外されているため既定では扱わない(--include=user_data で明示)
    • eccube:mail-template:remove を追加(--prune に必要。Phase 4 で「代替が未整備」として ~ にしていた admin_setting_shop_mail_delete の案内も埋まる)
  • Phase 6 — 分離モードの E2E 検証(Phase 5 までで仕様が確定してから着手)
    • docker-compose.permission-lanes.yml を起動する CI ジョブ(起動・eccube:doctor:permissions・フロント / 管理画面の応答・レーン境界の確認)
    • CLI でプラグインをインストールし、Playwright で管理画面から反映を確認する(e2e/tests/plugin-install.spec.tsインストール() を CLI 実行に差し替えて流用)
    • Phase 4 で確定した「レーン S へ書き込む管理画面機能」の応答(403 / 書き込みエラー)を固定する
  • Phase 7 — ドキュメントと AI エージェント向け Skill の整備(最終フェーズ。Phase 6 までで仕様と挙動が確定してから着手)
    • Phase 7a — AI エージェント向け Skill
      • .claude/skills/eccube-permission-lanes/SKILL.md を新設(レーンの判断基準、CLI の実行ユーザーの選び方、eccube:doctor:permissions の読み方、レーン S へ実行時に書き込むコードを書かないこと)
      • AGENTS.md の Skill 索引テーブルへ 1 行追加する(.codex/skills / .agents/skills は symlink のため同期作業は不要)
      • 既存 Skill を本 issue の変更へ追従させる
        • eccube-command — 終了コード 3(完了したが手動操作が必要)の規約、--dry-run / --format=json / stdin の作法
        • eccube-service — DB レコードと twig ファイルを対で扱う Service の書き方(どちらを先に処理するか、トランザクションの張り方)
        • eccube-twig-templatevar/build / var/cache / var/runtime の 3 分割と、テンプレート更新後に必要な操作
        • eccube-plugin — 分離した構成では管理画面からのプラグイン操作が使えず eccube:plugin:* が正であること
        • eccube-customizeapp/Customize から実行時にレーン S へ書き込まないこと
      • AGENTS.md / llms.txt の「キャッシュ / データベース」「権限を分離した環境」節を最終状態へ揃える
    • Phase 7bdoc4.ec-cube.net_pages/ 配下)
      • _pages/quickstart/permission.md を 3 レーンモデルへ全面改訂(現行は「Web サーバーに書き込み権限を与える」前提で書かれている)
      • _pages/quickstart/cli.mdeccube:doctor:permissions / eccube:cache:build / eccube:page:* / eccube:block:* / eccube:mail-template:* / eccube:asset:* / eccube:user-data:* / eccube:env:* / eccube:keystore:* を追加し、終了コード 3 の意味を明記する
      • _pages/quickstart/dotenv.mdECCUBE_PERMISSION_LANES / ECCUBE_UMASK / ECCUBE_CLI_LOG_TO_FILE / ECCUBE_MAINTENANCE_FILE_PATH を追加する
      • _pages/quickstart/cautions_of_prod.md にデプロイ手順を追加する(アクセスを受ける前に eccube:cache:build を実行する。理由は Phase 2 の warmup 経路)
      • _pages/spec/directory-structure.mdvar/ の説明を 3 分割へ更新する
      • 4.3 → 4.4 のアップデート手順を新設する(_pages/quickstart/update423_43x.md に倣う)。var/ の構成変更、umask(0000) 廃止に伴う既存ファイルの権限、cache:clear から eccube:cache:build への移行を扱う
      • 共有レンタルサーバー(CLI 不可)向けの注記を入れる。権限を分離しない構成が既定であり、分離は任意であることを明示する

Phase 7 の受入基準:

  • 分離は任意であること、Phase 4 までは管理画面の一部機能が使えないことを、doc4 側でも明記する。
  • 4.3 からの移行手順は実機で通し、記載どおりに動くことを確認してからマージする。
  • 運用手順の正本は doc4 に置き、本リポジトリの AGENTS.md は開発時に必要な要約に留める(同じ手順を二重に書かない)。

Phase 1 は単独で価値があり(現状の終了コードの問題の修正を含む)、他と独立して進められます。
Phase 2 は Phase 1 の診断コマンドへレーン定義を追加するため、#7098 の上に積んでいます。

Phase 7 を最後に置くのは、Phase 4(管理画面の読み取り専用モード)と Phase 5(eccube:contents:export / import)で
運用手順が変わるためです。先に書くと書き直しになります。

Phase 6 のテストが揃うまでは単体テストで担保し、分離モードの動作は各 Phase の実装時に
docker-compose.permission-lanes.yml で手動確認します。Web サーバーと CLI が別 uid で、かつ
Apache 経由で動く構成でしか再現しない不具合があるため(例: Apache の PassEnvDATABASE_URL が無く
Web だけが .env の SQLite を掴む)、単体テストだけでは検出できません。

本 issue のスコープ外とするもの

  • SSH 経由のリモート実行機能の本体組み込み(wp-cli の --ssh 相当)
    wp-cli の --ssh はリモート側にも wp-cli が必要な ssh のプロキシです。EC-CUBE の場合リモートには本体があり
    bin/console も存在するため、ssh host 'cd /path && bin/console ...' と等価になります。
    接続先設定・鍵の管理を本体が持つ必要はないと考えます。CLI 側を stdin 対応・冪等・機械可読にしておけば十分です。
  • 設定値からコマンド文字列を読み取って実行する委譲機構(例: ECCUBE_CACHE_CLEAR_COMMANDsudo -u www-data ... を設定して自動実行する)
    .env の値を信頼してコマンドを実行する設計は、権限を分離するという本 issue の目的と相反します。
    必要な操作は終了コードとメッセージで通知し、sudo 等の制御は運用側のスクリプトに委ねます。
  • MCP サーバーへの書き込み系ツールの追加
    MCP は HTTP 経由で Web サーバー権限で動作するため、書き込み系を載せると本 issue の目的が達成できません。
    書き込み系コマンドは MCP レジストリに登録せず、通常の #[AsCommand] として実装します
    src/Eccube/Service/Mcp/Tool/ の既存 11 ツールはいずれも参照系のみ)。

関連情報 (Ref)

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    documentImprovements or additions to documentation

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions