概要(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 でプロジェクトルート自体の書き込み可否を、:209 の Finder で
上記ディレクトリ配下の全ファイルを再帰的に検査します。
一方、書き込み系機能を無効化するスイッチ ECCUBE_RESTRICT_FILE_UPLOAD は既に存在します
(app/config/eccube/packages/eccube.yaml:171-181、RestrictFileUploadListener)。
しかし有効にすると対象機能が 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.php の getRuntimeDir() / getLogDir()
var/sessions/{env}
framework.yaml:13。Web サーバー専用 。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,170(ECCUBE_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-77(UcpMessageSigner / 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/entity — SchemaService.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/.htaccess(deny from all)が担保しており、これはモードを
変えても変わりません(Web サーバー自身は読める必要があるため)。緩和で増えるのはローカル読み取りのみで、
.env が DB 資格情報を 0644 で持つ現状と同水準です。所有者専用に戻す場合は
ECCUBE_KEYSTORE_STRICT_PERMISSIONS=1 を設定します(読み取りを許す手当ては運用側で行い、
eccube:keystore:generate が読めない状態を検出してエラーにします)。
ただしPhase 3c 以前は実行時生成の経路しかありませんでした 。/.well-known/ucp(UcpDiscoveryController.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.php は persist/flush の直後に dumpFile を実行)。
このため scp や git 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・エージェントから扱えるようにする
show は apply の逆操作にする — eccube:page:show --route=guide > guide.twig で取り出せる
管理画面がコアテンプレートの写しを作らないようにする
twig の探索は app/template/{theme} を src/Eccube/Resource/template/default より優先します
(app/config/eccube/packages/twig.yaml の paths)。一方 *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.id は IDENTITY 採番で環境ごとに変わるためです。layout_name に一意制約が無いので、名前が一意でない場合は export でエラーにします(当てずっぽうに解決すると、取り込み先で別のレイアウトへ静かに貼り替わります)
ブロックの配置場所
dtb_block_position.section を Layout::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/build と var/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.yaml は cache も auto_reload も指定していないため、
cache は既定 true、auto_reload は debug(= %kernel.debug%)にフォールバックします。
さらに src/Eccube/Kernel.php に getBuildDir() が無いため 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 ディレクトリ側へ事前コンパイルします。
dev は auto_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/cache と var/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:clear は build / cache 双方の書き込み権限を要求 します。3 分割では var/build と var/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" directory で 500 になります。復旧できるのは
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 3 で composer install を中断させます。
自前のコマンド(eccube:cache:build / eccube:page:apply 等)は auto-scripts から呼ばれないため
終了コード 3 を返して構いません。
したがって次の対応を行います。
eccube:cache:build(新規コマンド) — cache:clear を使わず build ディレクトリのみを再生成する
(CacheClearCommand と同じ「別名で warmup → rename で差し替え」を build 側だけに適用し、var/runtime には触れない)。
書き込み権限が不足している場合は終了コード 3 と eccube:doctor:permissions への案内を返す
Eccube\Util\CacheUtil の見直し — kernel.terminate の cache:clear を、build ディレクトリへ書き込めるかで分岐させる。
書き込めない場合は実行時キャッシュのみ削除し、eccube:cache:build の実行を案内する。
clearTwigCache() は build 側も削除する(prod では build 側が読み取り専用キャッシュとして優先されるため、
runtime だけ消しても管理画面で更新したテンプレートが反映されない)
InstallController::$eccubeDirs を 3 レーンに合わせて見直す
自動 warmup の抑制
kernel.build_dir と kernel.cache_dir が別パスになると、Kernel::initializeContainer() が
コンテナ再構築のたびに enableOptionalWarmers() を呼びます(vendor/symfony/http-kernel/Kernel.php:559-563)。
これが 2 つの問題を起こします。
全テンプレートのコンパイルがデプロイ時以外でも走り、composer install 時のピークメモリが跳ね上がります。
実測値は次のとおりです。
経路
対策前
対策後
composer install(cache:warmup --no-optional-warmers)
219 MiB
91.5 MiB
eccube:cache:build
—
169 MiB
テンプレートのコンパイル結果は事前生成が必須ではない(build に無ければ実行時に runtime へ
フォールバックする)ため、twig.template_cache_warmer の kernel.cache_warmer タグを外し
(BuildDirCacheWarmerPass)、eccube:cache:build が明示的に実行します。
他の warmer は外しません。 とくに HTMLPurifier の SerializerCacheWarmer は
シリアライザキャッシュの基底ディレクトリを作る唯一の箇所で、外すと
HTMLPurifier_DefinitionCache_Serializer::_prepareDir() が
Base directory ... does not exist の E_USER_WARNING を出し、debug 環境では
Symfony のエラーハンドラがこれを例外へ変換するためテンプレートの描画が失敗します。
umask の是正
アプリケーションが作成するディレクトリ・ファイルのパーミッションは、実行経路によって次のように異なっていました。
実行経路
umask
生成されるディレクトリ / ファイル
Web(prod)
OS 既定(通常 022)
0755 / 0644
Web(dev)
index.php が if ($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/console が apply_umask() を呼ぶ。既定値と意図は eccube.yaml の eccube_umask で宣言)。
未設定(既定)— OS / PHP-FPM の既定 umask に従う
ECCUBE_UMASK=0000 — 4.3 以前と同じ挙動(ディレクトリ 0777 / ファイル 0666)に戻す。
Web サーバーと CLI が別ユーザーで、かつ双方が同じファイルへ書き込む必要がある環境向け
mkdir() にリテラルで 0777 を渡していた箇所(PluginService.php、EntityProxyService.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_cache(var/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() から実測します。優先順は次のとおりです。
var/sessions/{env}/ 配下 — framework.yaml:13 の save_path。Web リクエストでのみ生成される ため最も確実。
ただし推奨どおり 0700 に絞ると CLI からは一覧できないため、その場合は次の候補へフォールバックする
html/upload/temp_image・html/upload/refund_request/{save,temp} 配下 — アップロードでのみ生成される。
html/upload/save_image は配布画像(no_image_product.png、sand-*.png 等)を含み、その所有者を拾って
誤判定するため判定には使わない
var/log/{env}/ 配下 — bin/console 実行でも書き込まれるため、1・2 が得られないときの補助材料に留める。
なお prod のログは %kernel.logs_dir%/%kernel.environment%/site.log(rotating_file)で、
main ハンドラが fingers_crossed(action_level: error)のため平常時はファイルが生成されません
いずれも見つからない場合は「判定不能」の警告とし、確認方法(ps aux | grep -E 'php-fpm|httpd|apache2' 等)を案内する
ext-posix は composer.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:46 の Process は 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:47 の file_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_urls(eccube.yaml:172-181)に、同種の書き込みを行う
admin_setting_shop_mail(MailController.php:108,116)が含まれていないため追加を検討します
段階的な実装
Phase のチェックは PR のマージ をもって付けます。
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 の PassEnv に DATABASE_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_COMMAND に sudo -u www-data ... を設定して自動実行する)
.env の値を信頼してコマンドを実行する設計は、権限を分離するという本 issue の目的と相反します。
必要な操作は終了コードとメッセージで通知し、sudo 等の制御は運用側のスクリプトに委ねます。
MCP サーバーへの書き込み系ツールの追加
MCP は HTTP 経由で Web サーバー権限で動作するため、書き込み系を載せると本 issue の目的が達成できません。
書き込み系コマンドは MCP レジストリに登録せず、通常の #[AsCommand] として実装します
(src/Eccube/Service/Mcp/Tool/ の既存 11 ツールはいずれも参照系のみ)。
関連情報 (Ref)
概要(Overview)
EC-CUBE は現状、リポジトリのほぼ全体に Web サーバーの書き込み権限があることを前提に動作します。
このため、公式ドキュメント(https://doc4.ec-cube.net/permission)に沿ってパーミッションを厳格化すると、
管理画面の多くの機能が動作しなくなります。
書き込み権限の要求範囲は
Eccube\Controller\Install\InstallControllerに定義されています。加えて
InstallController.php:197でプロジェクトルート自体の書き込み可否を、:209のFinderで上記ディレクトリ配下の全ファイルを再帰的に検査します。
一方、書き込み系機能を無効化するスイッチ
ECCUBE_RESTRICT_FILE_UPLOADは既に存在します(
app/config/eccube/packages/eccube.yaml:171-181、RestrictFileUploadListener)。しかし有効にすると対象機能が 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/logKernel.phpのgetRuntimeDir()/getLogDir()var/sessions/{env}framework.yaml:13。Web サーバー専用。CLI から書く必要がないため0700とし、他ユーザーからの一覧・読み取りも禁止するhtml/upload/save_image,html/upload/temp_imageProductController.php:312,578html/upload/refund_request/{save,temp}RefundRequestService.php:61,76,127(フロントの会員が投稿するため)SystemService.php:144,170(ECCUBE_MAINTENANCE_FILE_PATH。既定がプロジェクトルート直下の場合はvar/配下への変更を検討)レーン W であっても、CLI から書き込む必要はありません。デプロイやバッチでレーン W を触る操作は
sudo -u www-data bin/console ...のように Web サーバーのユーザーとして実行します。レーン S — SSH ユーザー所有(Web サーバーは読み取りのみ)
app/template/**PageController.php:151,BlockController.php:143,MailController.php:108,116eccube:page:*/eccube:block:*/eccube:mail-template:*(Phase 3a #7105)html/user_data/**CssController.php:54,JsController.php:55,FileController.php:203,334eccube:asset:*/eccube:user-data:*(Phase 3b #7114)app/Plugin,app/PluginData,app/proxy,html/plugin,vendorPluginService,EntityProxyService.php:87,94eccube:plugin:*composer.json/composer.lockComposerApiServiceeccube:composer:*.envSecurityController.php:108,123,TemplateController.php:95eccube:env:*(Phase 3b #7114)app/keystore/**FilesystemKeyStore.php:59-77(UcpMessageSigner/AcpMessageSignerが実行時に生成)eccube:keystore:*(Phase 3c #7117)var/build/{env},var/cache/{env}eccube:cache:buildプラグイン管理は既に CLI が揃っており、コンテンツ系と
.envは Phase 3a / 3b、鍵は Phase 3c で整備しました。レーン S に未整備の導線は残っていません(テンプレートのアップロードは本 issue のスコープ外)。
これらを一括で入出力する
eccube:contents:export/importは Phase 5(#7121)で整備しました。注意が必要な箇所
app/PluginData— プラグインが実行時にデータを書き込む用途があるため、プラグインによってはWeb サーバー権限が必要になります。ドキュメント上で注記が必要です。
app/proxy/entity—SchemaService.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/.htaccess(deny from all)が担保しており、これはモードを変えても変わりません(Web サーバー自身は読める必要があるため)。緩和で増えるのはローカル読み取りのみで、
.envが DB 資格情報を0644で持つ現状と同水準です。所有者専用に戻す場合はECCUBE_KEYSTORE_STRICT_PERMISSIONS=1を設定します(読み取りを許す手当ては運用側で行い、eccube:keystore:generateが読めない状態を検出してエラーにします)。ただしPhase 3c 以前は実行時生成の経路しかありませんでした。
/.well-known/ucp(UcpDiscoveryController.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.phpはpersist/flushの直後にdumpFileを実行)。このため
scpやgit pullでファイルを配布するだけでは機能せず、両者を一体で扱う CLI が必要です。設計原則
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・エージェントから扱えるようにするshowはapplyの逆操作にする —eccube:page:show --route=guide > guide.twigで取り出せる管理画面がコアテンプレートの写しを作らないようにする
twig の探索は
app/template/{theme}をsrc/Eccube/Resource/template/defaultより優先します(
app/config/eccube/packages/twig.yamlのpaths)。一方*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 で入出力します。テンプレートの本文はアーカイブへ複製しません。 EC-CUBE のカスタマイズ運用では
src/Eccube/Resource/template/**を直接カスタマイズし、脆弱性パッチとバージョンアップをgit mergeで取り込むのが一般的で、テンプレートは既にリポジトリで管理されています。そこへ写しを持つと二重管理になり、merge で解決できなくなります。
app/template/user_data/foo.twigをコミットしてpages.yamlに 1 行足せば、importがそのページを作ります。テンプレートの内容は書き換えません。dtb_layout.idはIDENTITY採番で環境ごとに変わるためです。layout_nameに一意制約が無いので、名前が一意でない場合は export でエラーにします(当てずっぽうに解決すると、取り込み先で別のレイアウトへ静かに貼り替わります)dtb_block_position.sectionをLayout::TARGET_ID_*の名前(header/side_left…)へ変換して持ちます。差分から配置が読めるようにするためです--pruneEDIT_TYPE_USER)・削除可能なブロック / メールテンプレート・どのページからも参照されていないレイアウト(Layout::isDeletable())のみですeccube:doctor:permissionsを案内しますhtml/user_datacustomize.css/customize.js以外は.gitignoreで意図的に除外されている(アップロード物)ため既定では扱いません。リポジトリ丸ごと管理する構成では--include=user_dataを指定します本文がアーカイブに無いため、レコードはあるのにテンプレートが見つからない状態は取り込めません。
NotBlankのエラーでは原因が分からないので、配置先のパスを添えて弾きます。3.
var/buildとvar/cacheの分離(キャッシュ生成の CLI 一本化)現状、テンプレートのコンパイル結果はリクエスト処理中に Web サーバーが生成します。
権限が不足しているとキャッシュ生成に失敗し、システムエラーになるケースが報告されています。
キャッシュ生成を CLI に一本化したいというのが要望です。
Symfony 7.4 にはこれを実現する機構があり、EC-CUBE はまだ利用していません。
現状(
getBuildDir()未実装)vendor/symfony/twig-bundle/DependencyInjection/TwigExtension.php:170-177app/config/eccube/packages/twig.yamlはcacheもauto_reloadも指定していないため、cacheは既定 true、auto_reloadはdebug(=%kernel.debug%)にフォールバックします。さらに
src/Eccube/Kernel.phpにgetBuildDir()が無いためkernel.build_dir === kernel.cache_dirとなり、条件を無条件に満たして
cache = var/cache/{env}/twigになります。結果として prod では、
auto_reload = falseのため、既存テンプレートを更新しても再コンパイルされない(キャッシュ削除が必要)という状態になっています。
提案
vendor/symfony/twig-bundle/Resources/config/twig.php:86-105には 3 層のキャッシュが定義されています。Kernel::getBuildDir()をgetCacheDir()と別パスにするだけで上記の条件分岐を通らなくなり、この 3 層構成(読み取り専用を優先し、無い場合のみ runtime にフォールバック)が有効になります。
TemplateCacheWarmerが build ディレクトリ側へ事前コンパイルします。devはauto_reload = %kernel.debug% = trueのため第 1 条件で従来どおり単層のキャッシュを使い、prod のみが読み取り専用運用に切り替わります(開発体験は変わりません)。
なお
runtime_cacheが指す%kernel.cache_dir%/twigは、後述のvar/runtime分離によりvar/runtime/{env}/twigへ差し替えます。var/build/{env}(新設)Web は読み取りのみ
eccube:cache:build(CLI)var/cache/{env}Web は読み取りのみ
var/runtime/{env}(新設)var/log,var/sessions/{env}var/runtimeを新設する理由Kernel::buildContainer()(vendor/symfony/http-kernel/Kernel.php:615-623)はbuild と cache の双方に書き込み権限を要求します。
つまり
var/cache/{env}を Web サーバー所有のままにすると、SSH ユーザーはコンテナを再生成できません。そこで リクエスト処理中に書き込まれるものを
var/runtime/{env}へ集約し、var/cacheとvar/buildを SSH ユーザー所有にします。移設先はすべて設定で変更できます。cache.apppoolframework.cache.directory(既定が%kernel.share_dir%起点のためKernel::getShareDir()で追従)cache.systempoolRuntimeCacheDirPassで差し替えmcp.http.session.directoryframework.profiler.dsnRuntimeCacheDirPassで差し替えeccube_runtime_dir実装上の制約:
cache:clearの扱いvendor/symfony/framework-bundle/Command/CacheClearCommand.phpcache:clearは build / cache 双方の書き込み権限を要求します。3 分割ではvar/buildとvar/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" directoryで 500 になります。復旧できるのは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 3でcomposer installを中断させます。自前のコマンド(
eccube:cache:build/eccube:page:apply等)は auto-scripts から呼ばれないため終了コード
3を返して構いません。したがって次の対応を行います。
eccube:cache:build(新規コマンド) —cache:clearを使わず build ディレクトリのみを再生成する(
CacheClearCommandと同じ「別名で warmup → rename で差し替え」を build 側だけに適用し、var/runtimeには触れない)。書き込み権限が不足している場合は終了コード
3とeccube:doctor:permissionsへの案内を返すEccube\Util\CacheUtilの見直し —kernel.terminateのcache:clearを、build ディレクトリへ書き込めるかで分岐させる。書き込めない場合は実行時キャッシュのみ削除し、
eccube:cache:buildの実行を案内する。clearTwigCache()は build 側も削除する(prod では build 側が読み取り専用キャッシュとして優先されるため、runtime だけ消しても管理画面で更新したテンプレートが反映されない)
InstallController::$eccubeDirsを 3 レーンに合わせて見直す自動 warmup の抑制
kernel.build_dirとkernel.cache_dirが別パスになると、Kernel::initializeContainer()がコンテナ再構築のたびに
enableOptionalWarmers()を呼びます(vendor/symfony/http-kernel/Kernel.php:559-563)。これが 2 つの問題を起こします。
全テンプレートのコンパイルがデプロイ時以外でも走り、
composer install時のピークメモリが跳ね上がります。実測値は次のとおりです。
composer install(cache:warmup --no-optional-warmers)eccube:cache:buildテンプレートのコンパイル結果は事前生成が必須ではない(build に無ければ実行時に runtime へ
フォールバックする)ため、
twig.template_cache_warmerのkernel.cache_warmerタグを外し(
BuildDirCacheWarmerPass)、eccube:cache:buildが明示的に実行します。他の warmer は外しません。 とくに HTMLPurifier の
SerializerCacheWarmerはシリアライザキャッシュの基底ディレクトリを作る唯一の箇所で、外すと
HTMLPurifier_DefinitionCache_Serializer::_prepareDir()がBase directory ... does not existのE_USER_WARNINGを出し、debug 環境ではSymfony のエラーハンドラがこれを例外へ変換するためテンプレートの描画が失敗します。
umask の是正
アプリケーションが作成するディレクトリ・ファイルのパーミッションは、実行経路によって次のように異なっていました。
index.phpがif ($debug)の中でumask(0000)bin/consoleが無条件にumask(0000)つまり 本番でも CLI が作成したものだけが world-writable になり、同一サーバーの他ユーザーから
書き換えられます。レーン W を「Web サーバー所有」にしても、アプリケーションが 0777 で作り直すため
分離が成立しません。
umask(0000)を両方から取り除き、環境変数ECCUBE_UMASK(8 進数表記)で任意に設定できるようにします。umask はコンテナを生成するより前に決める必要があるため、コンテナのパラメータではなく環境変数から読み込みます
(
index.php/bin/consoleがapply_umask()を呼ぶ。既定値と意図はeccube.yamlのeccube_umaskで宣言)。ECCUBE_UMASK=0000— 4.3 以前と同じ挙動(ディレクトリ 0777 / ファイル 0666)に戻す。Web サーバーと CLI が別ユーザーで、かつ双方が同じファイルへ書き込む必要がある環境向け
mkdir()にリテラルで0777を渡していた箇所(PluginService.php、EntityProxyService.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_cache(var/runtime/{env}/twig)へフォールバックしてコンパイルされます。Symfony の設計上これは避けられないため、warmup 漏れの検出
(prod で
var/runtime/{env}/twigにファイルが生成されていたら警告)を後述の診断コマンドに含めます。4. 診断コマンド
eccube:doctor:permissions3 レーンの期待値と実際の所有者・パーミッションを突き合わせ、差分と修正方法を出力します。
判定に
is_writable()は使えません。is_writable()が返すのは実行ユーザー(= CLI ユーザー)から見た可否だけで、Web サーバーから書けるかどうかは分からないためです。所有者 uid・グループ gid・パーミッションビットから推定します。
Web サーバーの補助グループ・ACL・SELinux までは判定できないため、出力には「推定」であることを明記します。
Web サーバーの実行ユーザー名は環境ごとに異なるため、コード中に固定値を持たせず、
Web サーバーでしか生成されないファイルの
fileowner()/filegroup()から実測します。優先順は次のとおりです。var/sessions/{env}/配下 —framework.yaml:13のsave_path。Web リクエストでのみ生成されるため最も確実。ただし推奨どおり
0700に絞ると CLI からは一覧できないため、その場合は次の候補へフォールバックするhtml/upload/temp_image・html/upload/refund_request/{save,temp}配下 — アップロードでのみ生成される。html/upload/save_imageは配布画像(no_image_product.png、sand-*.png等)を含み、その所有者を拾って誤判定するため判定には使わない
var/log/{env}/配下 —bin/console実行でも書き込まれるため、1・2 が得られないときの補助材料に留める。なお prod のログは
%kernel.logs_dir%/%kernel.environment%/site.log(rotating_file)で、mainハンドラがfingers_crossed(action_level: error)のため平常時はファイルが生成されませんps aux | grep -E 'php-fpm|httpd|apache2'等)を案内するext-posixはcomposer.jsonの require に含まれていないため、posix_getpwuid()が使えない環境では uid を数値で表示します。出力は既定が人間向けのテーブル、
--format=jsonで機械可読とします。終了コードは0= 問題なし /1= 要対応の NG あり /2= オプション不正(Command::INVALID)です。既存コマンドへも同じ判定を適用します。現在
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:46のProcessは 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:47のfile_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_urls(eccube.yaml:172-181)に、同種の書き込みを行うadmin_setting_shop_mail(MailController.php:108,116)が含まれていないため追加を検討します段階的な実装
Phase のチェックは PR のマージをもって付けます。
eccube:doctor:permissions(3 レーンの期待値と実際の所有者・パーミッションの突き合わせ)is_writable()は実行ユーザーから見た可否しか返さないため、所有者 uid / グループ gid / パーミッションビットから判定)eccube:plugin:*6 コマンドの終了コード修正(3= 完了したが手動操作が必要)docker-compose.permission-lanes.yml)var/build/var/cache/var/runtimeの 3 分割とキャッシュ生成の CLI 一本化 / 実装完了・レビュー中 feat: キャッシュ生成を CLI へ一本化し, 実行時に書き込むキャッシュを分離する #7100Kernel::getBuildDir()とvar/runtimeの新設(実行時に書き込まれるものを集約)eccube:cache:build(cache:clearが使えない構成でも build のみ再生成)CacheUtilの見直し(build へ書き込めるかで分岐し、書けなければ実行時キャッシュのみ削除)umask(0000)の廃止とECCUBE_UMASK化cache:clearで実行時 cache pool を削除する(RuntimeCachePoolClearer)eccube:page:*/eccube:block:*/eccube:mail-template:*/eccube:asset:*/eccube:user-data:*/eccube:env:*/eccube:keystore:*)eccube:page:*/eccube:block:*/eccube:mail-template:*(DB レコードと twig を対で扱う Service を新設し, 管理画面からも同じ経路を通す)/ 実装完了・レビュー中 feat: ページ・ブロック・メールテンプレートを CLI から操作できるようにする #7105eccube:asset:*/eccube:user-data:*/eccube:env:*(html/user_dataのパス検証をFileControllerから抽出)/ 実装完了・レビュー中 [4.4] html/user_data と .env を CLI から操作できるようにする (#7072 Phase 3b) #7114eccube:asset:show|apply(html/user_data/assets/{css,js}/customize.*)eccube:user-data:list|show|put|remove(html/user_data/**。html/はドキュメントルート配下のため、管理画面のファイル管理と同じ拡張子許可リストeccube_file_uploadable_extensionsを CLI にも適用する)eccube:env:get|set(.env。値はgetで単一キーのみ返し、一括ダンプは提供しない)UserDataFileServiceの新設(FileControllerのcheckDir()/getUserDataDir()/getJailDir()/ アップロード検証を抽出し、管理画面と CLI が同じ検証を通る)html/user_data_evilを配下と誤判定する / リンク先が存在しないシンボリックリンクを素通しし配下の外へ書き込める)eccube:env:setはビルド再生成を別プロセスで実行する(CacheBuildCommandは同一プロセスでカーネルを reboot するため、起動時に読み込んだ古い.envを焼き込む)。.env.local.phpがあるなど反映されない場合は終了コード3SecurityController/TemplateControllerの.env書き込みをEnvFileService::set()へ集約し、file_put_contents()の失敗(戻り値false・短い書き込み)を検出するCssController/JsControllerの読み取りを書き込み権限から切り離す(Phase 4 から前倒し。下記「5. 管理画面を読み取り専用モードに対応させる」参照)eccube:keystore:*(署名鍵・共有シークレットを CLI で事前配置する。上記「注意が必要な箇所」のapp/keystore参照)/ 実装完了・レビュー中 [4.4] eccube:keystore:* で署名鍵を CLI から事前配置できるようにする (#7072 Phase 3c) #7117eccube:keystore:generate|list|show。KeyStoreInterface経由で purpose 単位に冪等)--forceを指定したときのみ。鍵の有無と読み取り可否を分けて判定し、読めないだけの鍵を未生成とみなして上書きしない)showは鍵素材を表示しない(署名鍵は公開鍵 JWK と kid、共有シークレットはアルゴリズムと長さのみ)KeyPurposeInterfaceへ集約し、CLI と実行時の自動生成が同じ経路を通る(プラグインから用途を追加できる seam を兼ねる)0755/0644にする(ECCUBE_KEYSTORE_STRICT_PERMISSIONS=1で0700/0600)。作成したディレクトリ階層のモードは umask に依らず明示する1(署名と discovery はリクエスト処理中に行われるため、置いただけでは成立しない)eccube:doctor:permissionsがapp/keystoreをレーン S として NG にする現状の判定はそのままとし、本コマンドを対処手段として案内するECCUBE_RESTRICT_FILE_UPLOADの対象見直し / 実装完了・レビュー中 [4.4] 管理画面を読み取り専用モードに対応させ, ECCUBE_RESTRICT_FILE_UPLOAD の対象漏れを埋める (#7072 Phase 4) #7119ECCUBE_RESTRICT_FILE_UPLOAD=1で 403 を返すのをやめ、内容は表示し保存操作のみ無効化する(安全なメソッドは通し、書き込みを伴うメソッドだけ 403 にする)TwigInitializeListener::getDisplayEccubeNav()。辿れないと「内容は表示する」が成立しないため)eccube_restrict_file_upload_urlsをルート名 → CLI コマンドのマップにし、共通バナー@admin/notice_read_only.twigで表示)Store/template.twigのenvWritableと同じ形。CSS / JS 管理は Ace の構文チェックがprop('disabled', false)で戻すため JS 側にもガードを入れる)FileController側でcreate/uploadだけを拒否する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 本)admin_store_authentication_setting)は対象にしない。composer.jsonを書くが保存先はdtb_base_infoで composer 側は副作用であり、塞ぐと認証キーの登録手段が消えるためeccube:contents:export/import(Git 運用)/ 実装完了・レビュー中 feat(cli): eccube:contents:export / import でコンテンツ定義を Git 管理できるようにする (#7072 Phase 5) #7121dtb_page/dtb_page_layout/dtb_block/dtb_mail_template/dtb_layout/dtb_block_position)だけを扱う*ContentService::save()が無条件にdumpFile()していたのをやめ、本文が現在の内容(ファイルが無ければコアのテンプレート)と同じ場合は書き出さない。管理画面でコアページのメタ情報だけを変更してもapp/templateに影ができなくなるtrim(既定で有効)に合わせて正規化する。揃えないとコアのテンプレートは末尾の改行だけで差分と判定され、毎回写しを書き出してしまうPageContentService::readTemplate()の@user_data名前空間へのフォールバックを除去。書き込み先と同じディレクトリを指すため成功し得ず、見つからなかった問い合わせがFilesystemLoader::$errorCacheに残って同じプロセスで書き出した直後のテンプレートを読めなくしていたNotBlankで弾かれ、コミット済みのテンプレートからレコードを作れなかった)dtb_layout.idは環境ごとに変わる)。名前が一意でない場合は export でエラーにし、取り込み時に別のレイアウトへ静かに貼り替わることを防ぐdtb_block_position.section)はLayout::TARGET_ID_*を名前へ変換して持つ--prune(既定は無効)。削除できるのはユーザーが作成したページ・削除可能なブロック / メールテンプレート・どのページからも参照されていないレイアウトのみeccube:doctor:permissionsを案内するhtml/user_dataはcustomize.css/customize.js以外が.gitignoreで除外されているため既定では扱わない(--include=user_dataで明示)eccube:mail-template:removeを追加(--pruneに必要。Phase 4 で「代替が未整備」として~にしていたadmin_setting_shop_mail_deleteの案内も埋まる)docker-compose.permission-lanes.ymlを起動する CI ジョブ(起動・eccube:doctor:permissions・フロント / 管理画面の応答・レーン境界の確認)e2e/tests/plugin-install.spec.tsのインストール()を CLI 実行に差し替えて流用).claude/skills/eccube-permission-lanes/SKILL.mdを新設(レーンの判断基準、CLI の実行ユーザーの選び方、eccube:doctor:permissionsの読み方、レーン S へ実行時に書き込むコードを書かないこと)AGENTS.mdの Skill 索引テーブルへ 1 行追加する(.codex/skills/.agents/skillsは symlink のため同期作業は不要)eccube-command— 終了コード3(完了したが手動操作が必要)の規約、--dry-run/--format=json/ stdin の作法eccube-service— DB レコードと twig ファイルを対で扱う Service の書き方(どちらを先に処理するか、トランザクションの張り方)eccube-twig-template—var/build/var/cache/var/runtimeの 3 分割と、テンプレート更新後に必要な操作eccube-plugin— 分離した構成では管理画面からのプラグイン操作が使えずeccube:plugin:*が正であることeccube-customize—app/Customizeから実行時にレーン S へ書き込まないことAGENTS.md/llms.txtの「キャッシュ / データベース」「権限を分離した環境」節を最終状態へ揃える_pages/配下)_pages/quickstart/permission.mdを 3 レーンモデルへ全面改訂(現行は「Web サーバーに書き込み権限を与える」前提で書かれている)_pages/quickstart/cli.mdにeccube: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.mdにECCUBE_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.mdのvar/の説明を 3 分割へ更新する_pages/quickstart/update423_43x.mdに倣う)。var/の構成変更、umask(0000)廃止に伴う既存ファイルの権限、cache:clearからeccube:cache:buildへの移行を扱うPhase 7 の受入基準:
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 の
PassEnvにDATABASE_URLが無くWeb だけが
.envの SQLite を掴む)、単体テストだけでは検出できません。本 issue のスコープ外とするもの
--ssh相当)wp-cli の
--sshはリモート側にも wp-cli が必要な ssh のプロキシです。EC-CUBE の場合リモートには本体がありbin/consoleも存在するため、ssh host 'cd /path && bin/console ...'と等価になります。接続先設定・鍵の管理を本体が持つ必要はないと考えます。CLI 側を stdin 対応・冪等・機械可読にしておけば十分です。
ECCUBE_CACHE_CLEAR_COMMANDにsudo -u www-data ...を設定して自動実行する).envの値を信頼してコマンドを実行する設計は、権限を分離するという本 issue の目的と相反します。必要な操作は終了コードとメッセージで通知し、
sudo等の制御は運用側のスクリプトに委ねます。MCP は HTTP 経由で Web サーバー権限で動作するため、書き込み系を載せると本 issue の目的が達成できません。
書き込み系コマンドは MCP レジストリに登録せず、通常の
#[AsCommand]として実装します(
src/Eccube/Service/Mcp/Tool/の既存 11 ツールはいずれも参照系のみ)。関連情報 (Ref)
getBuildDir())