EC-CUBE / EC-CUBE/ec-cube

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

Open
#7,072 1 comment 0 reactions 0 assignees View on GitHub
document 分離モード
Dominant language
PHP
Stars
788
Forks
719
Avg merge
4d 4h
Merged PRs (30d)
39

Description

## 概要(Overview)

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

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

```php
// 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 から読めず、その逆も成立しません。
鍵保管ディレクトリ自体は #6797(PR #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 #7117)。
分離モードで実測したところ、鍵を置かずに `/.well-known/ucp` を叩くと
`鍵格納ディレクトリ ... を作成できません` で **500** になり、`eccube:keystore:generate` で
事前配置すると **200** になることを確認しました。
なお**実行時の自動生成は残します**。CLI を使えない共有レンタルサーバーでも成立させるための
フォールバックであり、#6797 / #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
```

```bash
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`

```php
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 層のキャッシュが定義されています。

```php
->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 の双方**に書き込み権限を要求します。

```php
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`

```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 レーンの期待値と実際の所有者・パーミッションを突き合わせ、差分と修正方法を出力します
(Phase 1 #7098 で導入。`var/build` / `var/runtime` のレーン定義と、事前コンパイルされていないテンプレートの検出は Phase 2 #7100 で追加)。

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

- POSIX と同じく owner / group / other のうち**一致する 1 クラスだけ**を見る。uid が所有者に一致すれば owner のビット、
gid が所有グループに一致すれば group のビット、それ以外は other のビットで決める。other が緩くても所有者のビットが優先される
(uid 0 は常に可)
- ディレクトリは w に加えて x(エントリの作成・削除に必要)、r に加えて x(配下のファイルを開くのに必要)を要求する。
ファイルは w / r だけを見る
- 祖先ディレクトリの到達性も評価する。ルート(`/`)から親までを走査し、通り抜けられない祖先があれば対象自身の権限が正しくても NG とし、
塞いでいる祖先のパスと権限をヒントに出す。プロジェクトルートで止めないのは、共有ホスティングで `/home/{user}` が `0700` のケースを拾うため。
`stat()` はシンボリックリンクを解決するため、論理パスの祖先とリンク解決後の物理パスの祖先の**両方**を見る
(`html/upload` を別ボリュームへ逃がす構成等。物理パスへ置き換えるとリンク自体へ辿り着くまでの祖先が消える)。
`open_basedir` で祖先を参照できない場合は到達不能と断定せず WARN とする
- レーン S が world-writable(other に w)なら、Web サーバーの uid を特定できていなくても NG とする。
任意のローカルユーザーが書ける時点でレーンの前提が崩れているため、WARN(終了コード `0`)にすると見落とす
- 診断の実行ユーザーは `posix_geteuid()` / `posix_getegid()` で取る。`getmyuid()` / `getmygid()` は実行プロセスではなく
スクリプトファイル(`bin/console`)の所有者を返すため、`sudo -u www-data bin/console` のように所有者と実行ユーザーが異なると誤判定する。
`ext-posix` は `composer.json` の require に含まれず `disable_functions` で無効化されることもあるため、取得できない場合は「判定不能」とし、
レーン S の CLI 側の書き込み可否と、ログ由来の Web サーバー判定(CLI が書いたログと区別できない)を行わない

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

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

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

uid / gid はユーザー名を解決せず数値のまま表示します(`ext-posix` に依存しないため)。

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

```
[NG] var/build/prod — Web サーバーから書き込み可能です (想定: 読み取りのみ)
CLI (SSH ログインユーザー) からのみ書き込める権限へ変更してください.
[NG] html/upload/temp_image — Web サーバーから到達できません
祖先ディレクトリ /var/www/html/html/upload (uid=1000 gid=1000 0700) に実行権限がないため, 配下へ到達できません. 実行権限を付与するか, 所有者を変更してください.
[WARN] var/runtime/prod — Web サーバーから書き込めますが, 任意のローカルユーザーからも書き込めます
ECCUBE_UMASK に 0000 を設定していると, アプリケーションが作成するディレクトリは 0777, ファイルは 0666 になります. 同一サーバーの他ユーザーから書き換えられるため, 権限を分離できる環境では ECCUBE_UMASK を空にしてください.
```

既存コマンドへも同じ考え方を適用しています。`PluginCommandTrait::clearCache()` はキャッシュ削除の失敗を戻り値で返し、
このトレイトを使う 6 コマンド(`eccube:plugin:enable` / `disable` / `install` / `uninstall` / `update` / `schema-update`)は
本処理を完了させたうえで専用の終了コード **`3` = 完了したが手動操作が必要**(`EXIT_MANUAL_ACTION_REQUIRED`)と、必要な操作の案内を返します。
従来は `$io->error()` で表示するだけで `return 0` していたため、失敗しても成功として扱われていました
(`2` は Symfony の `Command::INVALID` が使用済みのため避けています)。

あわせて次の 2 点も修正済みです。

- `clearCache()` の `Process` に `kernel.project_dir` を cwd として渡し、既定タイムアウト(`?float $timeout = 60`)を無効化した。
従来は cwd 未指定のためプロジェクトルート以外から実行するとキャッシュクリアが失敗し、60 秒を超えると子プロセスが kill されて
キャッシュが中途半端に削除された状態で「削除できませんでした」と案内していた。`bin/console` を子プロセスで実行する
`eccube:env:set`(Phase 3b)も同じ扱いにしている
- `eccube:plugin:install --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(#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 のマージ**をもって付けます。子項目は実装が終わり PR を open した時点で付けます。

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

## 積み残し(マージ済みだが未解決)

- **プラグイン操作後のコンパイル済みコンテナの再生成**(`PluginCommandTrait::clearCache()`。#7117 のレビュー指摘 https://github.com/EC-CUBE/ec-cube/pull/7117#discussion_r3975839756 )

`eccube:plugin:*` は本処理のあと `bin/console cache:clear --no-warmup` を子プロセスで実行します。
`--no-warmup` ではコンパイル済みコンテナが再生成されないため、**終了コードが `0` でも次のリクエストで
Web サーバーがコンテナの生成を試みます**。権限を分離した構成では Web サーバーが `var/build` へ書けないため 500 になります。
現状の回避策は、プラグイン操作のあとに `bin/console eccube:cache:build` を別途実行することです。

この問題は `clearCache()` 側で `--no-warmup` を外す、あるいは続けて `eccube:cache:build` を実行する形では解決できません。
**実行中のプロセスは自身のコンパイル済みコンテナを作り直せない**ためです。コンテナを作り直す子プロセスは、
**子プロセス自身が読み込んでいる**コンテナのディレクトリだけを新しい build ディレクトリへ引き継ぎます
(`CacheClearCommand.php:117-118`。`$containerDir` は `new \ReflectionObject($kernel->getContainer())` から決まる)。
プラグインの導入・削除はコンテナの内容を変えるため子の新しいコンテナは別ディレクトリになり、
古いコンテナを読み込んだまま動いている親プロセスのディレクトリが消えます。その後 `console.terminate` で
サービスを遅延読み込みする際に `require` が失敗し、プラグインの操作自体は完了しているのにコマンドが異常終了します。

親が読み込んでいるコンテナが古くなっている(=子プロセスが別のコンテナを生成する)状態での実測:

| 子プロセスのコマンド | 親のコンテナディレクトリ |
|---|---|
| `cache:clear --no-warmup` | 消える |
| `cache:clear --no-optional-warmers` | 消える |
| `eccube:cache:build --no-twig` | 消える |

親と子が同じコンテナを読み込んでいる場合はそのディレクトリが引き継がれるため消えません。
現行の `cache:clear --no-warmup` 単体が動いているのはこの前提が成立しているからで、
コンテナを作り直そうとした時点で前提が崩れます。

解決にはプラグイン操作とキャッシュ再生成を別プロセスへ分ける設計変更が必要で、本 issue のスコープを超えます。
現状は `PluginCommandTrait` の docblock にこの制約を記載し、`eccube:cache:build` を別途実行する運用としています。

## 本 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)

- #5069 Web サーバの書き込み権限がない場合に FTP/SSH のユーザアカウントを入力で書き込みができるようにする(本 issue は別解の提案)
- #5070 EC-CUBE が作成するファイルのパーミッション設定の見直し
- #4795 パッケージスクリプトで細かくパーミッション設定する
- https://doc4.ec-cube.net/permission
- https://qiita.com/nanasess/items/19ef094309f50009c742
- https://zenn.dev/nanasess/articles/choice-hosting-services-of-eccube
- Symfony: [Configuring the Kernel(`getBuildDir()`)](https://symfony.com/doc/current/reference/configuration/kernel.html)

Contributor guide

Open the contributing guide

Research direction

Start with src/Eccube/Controller/Install/InstallController.php, src/Eccube/Kernel.php, app/config/eccube/packages/twig.yaml, and the existing CLI commands named in the issue. Trace each listed write path and Symfony's cache/build handling before proposing a smaller implementation slice. Done means the affected paths, commands, permissions, and tests or verification steps are explicitly covered without breaking normal web requests.

Written by the indexing model from the issue text.

Assessment

Tech stack
php, symfony
Domain
backend, build-system, cli, devops, infrastructure
Issue type
Feature
Difficulty
5/5
Estimated time
Over a week
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
28/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.