diff --git a/issues/PLAN64_secret-label-group-design.md b/issues/PLAN64_secret-label-group-design.md new file mode 100644 index 00000000..6e17ec49 --- /dev/null +++ b/issues/PLAN64_secret-label-group-design.md @@ -0,0 +1,323 @@ +# PLAN64: 機密の参照の見出しにグループの読み替えを出す設計 + +要求と受け入れ条件は [PLAN64_secret-label-group.md](PLAN64_secret-label-group.md) にある。この文書は「どう作るか」だけを扱う。 + +## 機能一覧 + +| # | 機能 | 誰が使うか | +| --- | --- | --- | +| F1 | 参照の見出しのグループ名が、読み替えの前と後(`default → nyle`)を出す | `env list` / `env backend test` / `env backend migrate` を打つ利用者 | +| F2 | **引数なしの `label()` で参照を表示する**エラー文言・警告・ログは、読み替える前の名前のままにする(`display_group` を直接呼ぶ文言は今も前後を出し、変えない) | 失敗の原因を読む利用者と、ログを読む開発者 | +| F3 | 参照の表示の文言を組み立てる場所を 1 つに保つ | devbase の開発者(新しい見出しを足す人) | +| F4 | 確定仕様が、どの文言がどちらの形になるかを重ならない形で述べる | 確定仕様の読み手 | + +## 構成要素 + +### コード + +| 要素 | 変更 | 責務 | +| --- | --- | --- | +| `SecretRef.label()`(`lib/devbase/env/secret_store.py`) | 変える | 参照の表示の文言を組み立てる唯一の場所。キーワード引数 `group_display` を足し、グループの括弧の中に入れる名前だけを外から受ける。省いたときは今までどおり `self.group`(読み替える前の名前)を入れる | +| `SecretStore.display_label(ref)`(同) | 足す | 見出し用の表示を作る唯一の口。`storage_group(ref.group)` で読み替えの要否を判定し、要るときだけ `OpenBaoSettings.display_group` の結果を `label(group_display=...)` へ渡す | +| `SecretStore.storage_group`(同) | 変えない | 「グループの表示に読み替えを出すか」の判定に使う。`backend` が `openbao` でない・設定が無い・`layout: flat`・グループが `None` のいずれでも `None` を返す | +| `OpenBaoSettings.display_group`(`lib/devbase/env/backend_config.py`) | 変えない | 読み替えの前と後の文字列(`default → nyle`)を作る | +| `cmd_env_list` の共通の節(`lib/devbase/commands/env.py`) | 変える | `env_file.ref.label()` を `store.display_label(env_file.ref)` にする | +| `_group_suffix`(同) | 変える | 引数に `store` を取り、`display_label` の差分を取る。文言は写さない | +| `cmd_env_backend_test`(`lib/devbase/commands/env_backend.py`) | 変える | 参照ごとの行の `ref.label()` を `store.display_label(ref)` にする | +| `cmd_env_backend_migrate` の完了表示(同) | 変える | 「サーバ上の機密はそのまま残っています」の一覧の `unit.server_ref.label()` を `display_label` にする | +| `_MigrationPlan._heading`(同) | 変える | 移行の計画の一覧の `unit.server_ref.label()` を `self.server_store.display_label(...)` にする | + +変えないものは次の 6 つである。 + +| 変えないもの | 理由 | +| --- | --- | +| `SecretRef` のフィールドと等価性 | 参照の等価性とキャッシュの鍵に入っている | +| `OpenBaoSettings.path_of` / `display_path` | 置き場のパスは今も読み替え後の名前を出している | +| `env backend status` の表示 | すでに `display_group` を使っている | +| `bundle.py` / `io_import.py` の「別の置き場のプロジェクト」の列挙 | 同上 | +| `display_label` を通らない 47 か所の `label()` の呼び出し | 決定 2・決定 5 | +| 一覧の桁幅(`:<24` / `:<28` / `:<40`) | 決定 7 | + +### 呼び出しの関係 + +図は `label()` を呼ぶ関係だけを描く。設定の読み込みと backend の選択は省く。 + +```mermaid +graph TD + subgraph 見出しを出す側 + L[cmd_env_list] + GS[_group_suffix] + BT[cmd_env_backend_test] + BM[cmd_env_backend_migrate
の完了表示] + MH[_MigrationPlan._heading] + end + subgraph 表示の口 + DL[SecretStore.display_label] + SG[SecretStore.storage_group] + DG[OpenBaoSettings.display_group] + end + subgraph 文言 + LB["SecretRef.label(group_display=…)"] + end + subgraph 誤りを伝える側 + E[エラー文言・警告・ログ・巻き戻しの説明
43 か所] + end + L --> DL + GS --> DL + BT --> DL + BM --> DL + MH --> DL + DL --> SG + DL --> DG + DL --> LB + E --> LB +``` + +`E` は `label()` を引数なしで呼ぶ。`DL` を経由しないことが F2 の実現方式である。 + +### 対象の数え方 + +`lib/` の `.label()` の出現は 52 件 / 10 ファイル(2026-09-22 に `grep -rn "\.label()" lib/` で実測。issue 本文と一致)。 +このうち 1 件(`lib/devbase/commands/env.py` の `_group_suffix` の docstring)は文字列の言及で、実行される呼び出しではない。 + +| 区分 | 件数 | 扱い | +| --- | ---: | --- | +| 見出し(読み替えのあるグループを持ちうる) | 5 | `display_label` へ寄せる | +| 見出し(参照が常にグループを持たない。`env encrypt` / `env decrypt` / `env rekey`) | 3 | 変えない | +| エラー文言・警告・ログ・巻き戻しの説明・使われていない委譲 | 43 | 変えない | +| docstring の言及 | 1 | 変えない | +| 合計 | 52 | | + +「変えない」は 47 件(3 + 43 + 1)で、内訳は上の図の `E`(43 件)、グループを持たない一覧(3 件)、docstring の言及(1 件)である。 + +## 配置 + +### システムの文脈 + +この変更が触る外部は無い。`devbase` はホストで動き、置き場のパス・サーバへの要求・キャッシュのファイルはいずれも変わらない。変わるのは端末の画面に出る文字列だけである。 + +```mermaid +graph LR + U[利用者の端末] --> D[devbase CLI] + D -->|変わらない| B[OpenBao サーバ] + D -->|変わらない| F[DEVBASE_ROOT のファイル] + D -->|変わる: 見出しの文字列| T[端末の画面] +``` + +### モジュールの置き場所 + +```text +lib/devbase/ +├── env/ +│ ├── secret_store.py # SecretRef.label に group_display / SecretStore.display_label を足す +│ └── backend_config.py # 変えない(display_group / storage_group をそのまま使う) +└── commands/ + ├── env.py # cmd_env_list の共通の節 / _group_suffix + └── env_backend.py # cmd_env_backend_test / cmd_env_backend_migrate の完了表示 / _MigrationPlan._heading +tests/ +├── env/test_secret_store_label.py # 新設。label の契約と display_label の分岐 +└── commands/test_env_group_label.py # 新設。読み替えのあるグループでのコマンドの出力 +docs/specifications/secret-backend.md # 2 か所の書き分けと list の見出しの例 +docs/user/env-backend.md # 見出しの例に読み替えのある場合を足す +docs/user/cli-reference/03-env.md # 同上 +CHANGELOG.md # Unreleased の Fixed +``` + +## 構造 + +```mermaid +classDiagram + class SecretRef { + +str kind + +Optional~str~ name + +str owner + +Optional~str~ group + +label(group_display) str + } + class SecretStore { + +config + +storage_group(group) Optional~str~ + +display_label(ref) str + } + class OpenBaoSettings { + +str layout + +Dict~str,str~ group_aliases + +storage_group(group) str + +display_group(group) str + } + class BackendConfig { + +str backend + +int version + } + SecretStore --> BackendConfig : config + BackendConfig --> OpenBaoSettings : openbao (Optional) + SecretStore ..> SecretRef : display_label(ref) + SecretRef <.. OpenBaoSettings : (依存しない) +``` + +`SecretRef` は `frozen=True` のままで、フィールドを足さない。`group` は等価性とキャッシュの鍵に入っているため、表示のための値をフィールドとして持たせない。 + +## 入出力の契約 + +### `SecretRef.label(*, group_display=None) -> str` + +| 引数 | 型 | 既定 | 意味 | +| --- | --- | --- | --- | +| `group_display` | `Optional[str]` | `None` | グループの括弧の中に入れる名前。`None` なら `self.group`(読み替える前の名前) | + +| 状況 | 返り値 | +| --- | --- | +| `self.group` が `None` | `グローバル` / `プロジェクト 'web'` / `個人のグローバル` / `個人のプロジェクト 'web'`(`group_display` を渡しても無視する) | +| `self.group` があり `group_display` が `None` | `グローバル(グループ default)` | +| `self.group` があり `group_display` が `default → nyle` | `グローバル(グループ default → nyle)` | + +**`group_display` は文字列で受け、`OpenBaoSettings` を受けない。** 参照が設定の型を知らない状態を保つためである。 + +### `SecretStore.display_label(ref) -> str` + +| 状況 | 返り値 | +| --- | --- | +| `storage_group(ref.group)` が `None`(`ref.group` が `None`、backend が `openbao` でない、設定が無い、`layout: flat`) | `ref.label()` と同じ文字列 | +| 読み替えが無い(`storage_group(ref.group) == ref.group`) | `ref.label()` と同じ文字列。`display_group` が `→` を付けないため | +| 読み替えがある | `ref.label(group_display='default → nyle')` | + +**判定を `storage_group` に任せるのは、`config.openbao` が `None` かどうかが backend の種類だけでは決まらないためである。** `backend: age` の設定に `openbao:` 節が残っていると、`config.openbao` は `None` にならない。判定しているのは `_openbao_from_dict`(`lib/devbase/env/backend_config.py`)である(2026-09-22 に確認)。`storage_group` は backend の種類・設定の有無・`layout` の 3 つをまとめて見る唯一の判定である。`ref_group` も同じものを使っている。 + +### 見出しの形 + +| コマンド | 行 | 読み替えが無いとき | 読み替えがあるとき | +| --- | --- | --- | --- | +| `env list` | 節の見出し | `=== グローバル(グループ kkg) (…) ===` | `=== グローバル(グループ default → nyle) (…) ===` | +| `env list` | 件数の行 | `グローバル(グループ kkg): 22変数` | `グローバル(グループ default → nyle): 22変数` | +| `env list` | プロジェクトの節 | `=== プロジェクト: web(グループ kkg) (…) ===` | `=== プロジェクト: web(グループ default → nyle) (…) ===` | +| `env backend test` | 参照ごとの行 | ` グローバル(グループ kkg) devbase/team/kkg/global 22 変数` | ` グローバル(グループ default → nyle) devbase/team/nyle/global 22 変数` | +| `env backend migrate` | 計画の一覧 | ` グローバル(グループ kkg) devbase/team/kkg/global 22 件: …` | ` グローバル(グループ default → nyle) devbase/team/nyle/global 22 件: …` | +| `env backend migrate --to age` | 完了後の一覧 | ` グローバル(グループ kkg) devbase/team/kkg/global` | ` グローバル(グループ default → nyle) devbase/team/nyle/global` | + +`version: 1` とファイル backend では、いずれの行にもグループが付かない(`ref.group` が `None`)。 + +### 失敗の形 + +`display_label` は例外を送出しない経路だけを通る。`storage_group` が `None` を返せば `label()` をそのまま返し、`None` 以外を返した時点でグループ名の検証は成功している。`display_group` はその直後に同じ `storage_group` を呼ぶため、新たに `BackendConfigError` になる場面が無い。終了コードを持たず、標準出力にも標準エラーにも自分では書かない。 + +## 処理の流れ + +```mermaid +sequenceDiagram + participant U as 利用者 + participant C as cmd_env_backend_test + participant S as SecretStore + participant O as OpenBaoSettings + participant R as SecretRef + + U->>C: devbase env backend test + C->>S: display_label(ref) + S->>O: storage_group(ref.group) + O-->>S: 'nyle'(読み替え後) + alt 読み替え後 ≠ 読み替え前 + S->>O: display_group(ref.group) + O-->>S: 'default → nyle' + S->>R: label(group_display='default → nyle') + else 同じ、または None + S->>R: label() + end + R-->>S: 'グローバル(グループ default → nyle)' + S-->>C: 同じ文字列 + C->>U: 見出しの行(隣に display_path) +``` + +エラー文言の経路はこの図に現れない。`label()` を引数なしで直接呼ぶためである。 + +## 非機能の実現方式 + +| 項目 | 条件 | 実現方式 | +| --- | --- | --- | +| 堅牢性 | 誤りを伝える文言の組み立てが、新たな例外を起こさない | `label()` を通して参照を出すエラー文言は、引数なしで呼ぶ。読み替えの解決(`storage_group` / `display_group`。名前の検証と予約語の検査で `BackendConfigError` を送出しうる)を新しく背負う文言を増やさない | +| セキュリティ | 見出しに機密の値が出ない | 変えるのはグループ名だけで、キーも値も触らない。グループ名は `backend.yml` の設定で、機密ではない | +| 互換性 | `version: 1` とファイル backend の出力が 1 文字も変わらない | `display_label` が `storage_group(ref.group) is None` で `ref.label()` を返す。`ref.group` はこれらの設定では常に `None`(`SecretStore.ref_group` の契約) | +| 可読性 | 一覧の桁が崩れない | 桁幅を変えない。読み替えの無い状態でも `プロジェクト 'carmo-ai'(グループ default)` は 31 文字で `:<28` を超えており(2026-09-22 に実測)、桁あふれはこの変更で始まるものではない | +| 性能 | 1 回の表示で増える処理 | `storage_group` は辞書の参照と正規表現の検証だけで、サーバへの要求もファイルの読み込みも増やさない。参照 1 件あたり最大 2 回呼ぶ | + +## 決定の記録 + +### 決定 1: グループの表示の責務を `label()` の契約へ寄せる(issue の `move_responsibility` を採る) + +`label()` の引数に `group_display` を足し、見出しの側は「括弧の中に入れる名前」だけを渡す。文言の組み立て(`(グループ …)` の括弧、`個人の` の接頭、チーム単位の文字列)は `label()` の中から出ない。`_group_suffix` が明記している従属関係(「文言は `SecretRef.label()` が持ち、ここでは写さずに差分だけを取り出す」)はそのまま保たれる。 + +見出しの側で `f'(グループ {display})'` を組み立てる案は採らない。同じ文言が 5 か所へ複製され、`label()` を直したときに見出しが追随しない。 + +### 決定 2: `label()` の既定の返り値は変えない + +読み替えの解決は `OpenBaoSettings.storage_group` を通り、グループ名の検証と予約語の検査で `BackendConfigError` を送出しうる。既定を読み替え後にすると、43 か所のエラー文言・警告・ログがこの解決に依存する。**誤りを伝える文言を組み立てる途中で新しい例外が起きる形**になり、元の失敗が利用者へ届かなくなる。この危険は仮想のものではない。`BackendConfigError` そのものの文言(`lib/devbase/env/backend_config.py` の `_group_of`)が `label()` を使っている。 + +`display_group` を直接呼んでいる文言(`lib/devbase/commands/env.py` の `_project_group_mismatch` など)は、この決定の対象ではない。設定が読める場所で組み立てており、今も読み替えの前後を出している。 + +既定を読み替え後にする案(52 か所すべてに及ぶ案)は採らない。上の理由に加えて、`SecretRef` が `frozen=True` の値であり、設定を持たないまま読み替えを解決する手段が無いためである。持たせるには全フィールドに設定への参照が要り、参照の等価性とキャッシュの鍵に影響する。 + +### 決定 3: 見出し用の表示を作る口を `SecretStore.display_label` の 1 つに置く + +見出しの側が `storage_group` と `display_group` と `label` を毎回組み合わせると、組み合わせ方の誤りが 5 か所で起こりうる。`config.openbao` が `None` の場合の見落としがこれにあたる。判定を 1 か所に閉じ、見出しの側は `store.display_label(ref)` だけを呼ぶ。 + +**`config.openbao` が `None` かどうかで backend の種類を判定しない。** `backend: age` に `openbao:` 節が残っていれば `None` にならないため、その判定は `backend: age` の端末をグループ別の置き場として扱いうる。受け入れ条件 6 のテストがこの取り違えをそのまま検査する。 + +`OpenBaoSettings` に口を置く案は採らない。`config.openbao` が `None` の場合と backend が `openbao` でない場合を、呼ぶ側が毎回確かめることになる。`SecretStore` は `storage_group` / `ref_group` / `same_storage_group` で既に同じ判定を引き受けている。 + +### 決定 4: `env backend migrate` の 2 つの一覧も直す + +`lib/devbase/commands/env_backend.py` には、参照の表示とサーバのパスを隣に並べる一覧がもう 2 つある。移行の計画の一覧と、`--to age` の完了後の「サーバ上の機密はそのまま残っています」の一覧である。直さなければ、同じ食い違い(`default` の隣に `nyle` のパス)がそこに残る。issue 本文はこの 2 か所を挙げていないため、受け入れ条件 3 を設計の時点で足した。 + +### 決定 5: `env encrypt` / `env decrypt` / `env rekey` の一覧は直さない + +これらが組む参照は `SecretRef.for_global()` / `SecretRef.for_project(name)` で、`group=` を渡さない。`ref.group` が常に `None` になる。参照を組んでいるのは `_select_refs`(`lib/devbase/commands/env_migrate.py`)と `_encrypted_refs`(`lib/devbase/commands/env_ops.py`)である(2026-09-22 に確認)。見出しにグループが出ないため、直しても出力が変わらない。 + +### 決定 6: 確定仕様は、どちらか一方を消さずに「文言の種類で分ける」形で解く + +`docs/specifications/secret-backend.md` の 2 つの記述は、どちらも正しい振る舞いを述べている。片方を消すと、残った側が対象外の文言まで巻き込む。 + +| 今の記述 | どう直すか | +| --- | --- | +| 「`label()` はグループがあれば `(グループ <名前>)` を後ろに付ける」 | 既定が読み替える前の名前であることを書く。`group_display` を渡すと前後になることを足す。エラー文言とログが既定を使う理由(読み替えの解決が失敗しうる)を添える | +| 「文言には読み替えの前と後を `default → nyle` の形で出す(`display_group`)」 | この規則を保ったまま、除くものを**引数なしの `SecretRef.label()` で参照を表示するエラー文言・ログ**に限ると書く。`display_group` を直接呼ぶ文言(`_project_group_mismatch` が組む `-p` の拒否の文言など)はエラーであっても前と後を出す | +| `list` の見出しの例(`with` の 1 つだけ) | 読み替えのある場合(`default → nyle`)を足す。`with` だけでは前後が一致して矛盾が表に出ない | + +### 決定 7: 一覧の桁幅を変えない + +`env backend test` の `:<28` は文字数で数え、全角文字の表示幅を数えない。読み替えの無い状態でも `プロジェクト 'carmo-ai'(グループ default)` は 31 文字で既に超えている。読み替えの表示で 7 文字増えるが、桁あふれの性質は変わらない。 + +幅を広げる案と、実際の最大長から幅を計算する案は採らない。どちらも読み替えの無いグループの出力を変え、受け入れ条件 5・6(変わらないこと)を崩す。桁揃えそのものの見直しは、この変更とは別の課題である。 + +## テスト設計 + +`DEVBASE_ROOT` は各テストが自分で差し替える既存の流儀に合わせる。使う道具と手本は次のとおりである。 + +| 何を | どこから | +| --- | --- | +| 設定の書き込み | `tests/conftest.py` の `configure_openbao`(`layout='group'`、`group_aliases={'default': 'nyle'}` を渡す) | +| `DEVBASE_ROOT` と作業ディレクトリ | `tests/conftest.py` の `openbao_root` fixture | +| 手本にする fixture | `tests/commands/test_env_user_axis.py` の `grouped`(すでに同じ読み替えを設定している) | + +**「変わらないこと」(受け入れ条件 5・6)は、既存テストだけでは確かめられない。** `env list` の見出しを固定している既存の assert は `'=== グローバル'` の前方一致で、見出しに `(グループ default → nyle)` が付いても通る(`tests/commands/test_env_user_axis.py:229-253`。2026-09-22 に確認)。読み替えの無い設定で`(グループ` が 1 つも出ないことを見る出力テストを新しく置く。 + +| 受け入れ条件 | 何で確かめるか | +| --- | --- | +| 1(`env backend test` の見出し) | `tests/commands/test_env_group_label.py` を新設。`layout='group'`・`group_aliases={'default': 'nyle'}` で `cmd_env_backend_test` を呼び、`capsys` の行に `グローバル(グループ default → nyle)` と `個人のグローバル(グループ default → nyle)` が出ることと、同じ行のパスが `…/team/nyle/global` であることを見る | +| 2(`env list` の見出し) | 同ファイル。`cmd_env_list` を `projects/web`(グループの宣言なし → `default`)で呼び、`=== グローバル(グループ default → nyle) (` と `=== プロジェクト: web(グループ default → nyle) (`、および件数の行 2 つを見る | +| 3(`env backend migrate` の 2 つの一覧) | 同ファイル。`cmd_env_backend_migrate(to='age', dry_run=True)` で**計画の一覧**の行に `グローバル(グループ default → nyle)` が出ることを見る。あわせて `dry_run=False`(偽サーバ)で `--to age` を通し、完了後の「サーバ上の機密はそのまま残っています」の一覧の行にも同じ見出しが出ることを見る。この 2 つは別の関数(`_MigrationPlan._heading` と `cmd_env_backend_migrate` の完了表示)が出すため、片方だけでは決定 4 を確かめられない | +| 4(読み替えの無いグループ) | 同ファイル。`projects/web` の `env` に `DEVBASE_ACCOUNT_GROUP=kkg` を書き、見出しが `(グループ kkg)` のままで `→` を含まないことを見る | +| 5(`version: 1`) | `tests/commands/test_env_group_label.py` に新規で 1 件。`configure_openbao(layout='flat')`(`version: 1`)で `cmd_env_list` と `cmd_env_backend_test` を呼び、**出力全体に `(グループ` が 1 つも出ない**ことを見る。既存の `tests/env/test_runtime.py`・`tests/env/test_groups.py` も変更なしで通す | +| 6(ファイル backend) | 同ファイルに新規で 1 件。`backend: age` に `openbao:` 節を残した設定で `cmd_env_list` を呼び、出力全体に `(グループ` が 1 つも出ないことを見る。既存の `tests/commands/test_env_user_axis.py` も変更なしで通す | +| 7(エラー文言) | `tests/env/test_secret_store_label.py` を新設。読み替えのあるグループの参照で `label()` を引数なしに呼ぶと `(グループ default)` になること、`label(group_display='default → nyle')` で前後が出ること、`group` が `None` の参照では `group_display` を渡しても無視されることを見る。あわせて、要求文書の受け入れ条件 7 が挙げる 2 つの文言に `→` が出ないことを見る。`layout: flat` でグループ付きの参照を拒む例外(`lib/devbase/env/openbao.py` の `OpenBaoBackend._check_group`)と、置き場の `DEVBASE_ACCOUNT_GROUP` を使わない旨の警告(`lib/devbase/env/runtime.py`)である | +| 8(`env backend status`) | 既存の `tests/commands/test_env_backend.py` を変更なしで通す | +| 9・10・11(文書) | 実装 Pull Request のレビューで読んで確かめる(自動の検査を置かない) | +| 12(退行) | `uv run pytest tests/ -q` の結果を実装 Pull Request の本文へ載せる | + +`display_label` の分岐(`storage_group` が `None` / 読み替えなし / 読み替えあり)は、受け入れ条件 1・4・5・6 のテストが 3 つとも通る。分岐だけの単体テストは `tests/env/test_secret_store_label.py` に 1 件置く。 + +## 未確認のまま残ること + +| 項目 | 内容 | +| --- | --- | +| 実機での見え方 | この端末の機密の置き場は本番の系のため、確認は読み取り(`env backend test` / `env list --keys-only` / `env backend status`)に限る。`env backend migrate` の一覧は `--dry-run` でも移行元の読み取りを伴うため、実機では確かめずテストだけで見る | +| CI | `release/v3.7.0` を base にした Pull Request では CI が 1 件も動かない(`.github/workflows/ci.yml` の対象が `main` だけ、#216)。手元の `uv run pytest tests/ -q` が唯一の証跡になる | +| `tests/conftest.py` の `DEVBASE_ROOT` の隔離 | #217(`release/v3.7.0` 未マージ)が入ると、新しいテストの `DEVBASE_ROOT` の扱いを揃え直す余地がある。この設計では既存の流儀のままにする | +| 桁揃えそのもの | 全角文字の表示幅を数えない桁揃えは、読み替えの有無に関わらず長い参照で崩れる。この変更では扱わない | diff --git a/issues/PLAN64_secret-label-group.md b/issues/PLAN64_secret-label-group.md new file mode 100644 index 00000000..11bf7e71 --- /dev/null +++ b/issues/PLAN64_secret-label-group.md @@ -0,0 +1,236 @@ +# PLAN64: 機密の参照の見出しに、グループの読み替えの前後を出す + +対象 issue: devbasex/devbase#188 + +- ワークフローモード: `standard` + - 根拠: 利用者が読む公開の出力の振る舞いを変える(`env backend test` と `env list` の見出し)。 + あわせて確定仕様 `docs/specifications/secret-backend.md` の自己矛盾を解く +- 参照: release Pull Request devbasex/devbase#212(`release/v3.7.0`) + +## 目的 + +`group_aliases` のある端末で、機密の参照の見出しに出るグループ名と、その隣に並ぶ置き場のパスが +同じグループを指していると読めるようにする。あわせて、確定仕様の「参照の表示」と「文言の表示」の +2 つの記述が食い違ったままにしない。 + +## 曖昧語の具体化 + +| 依頼文の語 | 具体化 | +| --- | --- | +| 「見出し」 | 利用者が正常系の一覧として読む行に限る。`env backend test` の参照ごとの行(`lib/devbase/commands/env_backend.py`)と、`env list` のチーム共通・個人共通・プロジェクトの節の `=== ... ===` と末尾の件数の行(`lib/devbase/commands/env.py`)。エラー文言・警告・ログは含まない | +| 「読み替えの前後を出す」 | `OpenBaoSettings.display_group(group)` の返り値をそのまま使う。読み替えがあれば `default → nyle`、無ければ `default`(`→` を付けない) | +| 「矛盾が解ける」 | 仕様書の 2 つの記述が、どの文言がどちらの形になるかを重ならない形で述べ、どちらを読んでも同じ結論になる。分かれ目は「引数なしの `SecretRef.label()` で参照を表示するか」で、`display_group` を直接呼ぶ文言はエラーであっても読み替えの前後を出す | +| 「変わらない」 | 出力の文字列がバイト単位で今と同じ | + +## 前提 + +- 前提 1: `version: 1` とファイル backend(`plaintext` / `age`)では `SecretRef.group` が常に `None` に + なる(`SecretStore.ref_group` の契約、確定仕様「参照のグループ」)。見出しにグループが付かない。 + したがって「見出しが変わらない」ことは、グループの有無で分岐する形にすれば構造として保てる +- 前提 2: **`config.openbao` が `None` かどうかで backend の種類を判定してはならない。** + `_openbao_from_dict`(`lib/devbase/env/backend_config.py`)は `backend: age` でも `openbao:` 節が + 残っていれば設定を返す。ファイル backend で `None` になるのは、節が無く `version: 1` のときだけである + (2026-09-22 に確認)。表示の分岐は `SecretStore.storage_group(ref.group)` に任せる。この 1 つが + backend の種類・設定の有無・`layout`・グループの有無をまとめて見て、当たらなければ `None` を返す +- 前提 3: `OpenBaoSettings.display_group` は `storage_group` を経由する。名前の検証と予約語の検査で + `BackendConfigError` を送出しうる。**誤りを伝える文言の組み立ての中では呼ばない。** 見出しは + 対応するパスの解決が成功した後に出る(`path_of` も `storage_group` を通る)。そのため + 見出しの経路で新たに失敗する場面は生じない +- 前提 4: 一覧の桁揃え(`env backend test` の `{...:<28}`)は文字数で数えており、全角文字の表示幅を + 数えない。読み替えの無い状態でも `プロジェクト 'carmo-ai'(グループ default)` は 31 文字で 28 を + 超えている(2026-09-22 に実測)。桁あふれは本 issue の変更で始まるものではない +- 前提 5: この端末の機密の置き場は本番の系である。実機での確認は読み取りに限る + (`env backend status` / `env backend test` / `env list --keys-only`)。`env set` / `migrate` / + backend の切り替えは行わない + +## 対象範囲 + +含む: + +- `SecretRef.label()` の契約(グループの表示をどう決めるか) +- `env backend test` の参照ごとの行の見出し +- `env backend migrate` の移行の計画の一覧と、`--to age` の完了後の「サーバ上の機密はそのまま残っています」の一覧 +- `env list` のチーム共通・個人共通・プロジェクトの節の見出しと末尾の件数の行 +- `docs/specifications/secret-backend.md` の 2 つの記述の書き分けと、`list` の見出しの例 +- 読み替えのあるグループを使う新しいテスト +- 利用者向け文書の見出しの例(`docs/user/env-backend.md`、`docs/user/cli-reference/03-env.md`)と CHANGELOG + +含まない: + +- エラー文言・警告・ログに出る `label()` の表示(前提 3 の理由で読み替え前の名前のまま) +- `env backend status` の表示(すでに `display_group` を使っており、変えない) +- `env encrypt` / `env decrypt` の一覧(`lib/devbase/commands/env_migrate.py`)と `env rekey` の一覧 + (`lib/devbase/commands/env_ops.py`)。参照を `group=` を渡さずに組んでいるため、`ref.group` が + 常に `None` になり、グループが見出しに出ない(`env_migrate.py:85-94`、`env_ops.py:56-63`。 + 2026-09-22 に確認) +- すでに `OpenBaoSettings.display_group` を直接呼んでいる文言。読み替えの前後を今も出しており、 + 変えない。`bundle.py` / `io_import.py` / `env_backend.py` の「別の置き場のプロジェクト」の列挙と、 + `lib/devbase/commands/env.py` の `_project_group_mismatch`(`--group` がプロジェクトのグループと + 違う置き場である旨の文言)がこれにあたる +- 一覧の桁幅(`{...:<28}` / `{...:<40}`)の変更(前提 4) +- `group_aliases` の設定方法・置き場のパスの組み立て・キャッシュの位置 +- `DEVBASE_ACCOUNT_GROUP` の警告文に付く ` --group <読み替える前の名前>` の引数 + (引数は読み替える前の名前でなければ通らないため、変えない) +- `tests/conftest.py` による `DEVBASE_ROOT` の隔離(別の Pull Request #217 が扱う) +- `lib/devbase/commands/env_migrate.py` の使われていない `Target.label`(#222 として起票。出力が変わらない) +- 新しい永続データ・画面の追加(そのため ER 図・テーブル定義・CRUD 図・画面遷移図を作らない) + +## 受け入れ条件 + +見出しの表示(#188): + +- [ ] 1. 前提: `backend: openbao` / `version: 2` / `group_aliases: {default: nyle}`、対象のグループが `default` + 操作: `devbase env backend test` を実行する + 結果: チーム共通の行の見出しが `グローバル(グループ default → nyle)` になる。個人共通は + `個人のグローバル(グループ default → nyle)`。隣に並ぶパスは今と同じ(`…/team/nyle/global`) + 検証: 新規テスト(`env backend test` の出力の行を読む) +- [ ] 2. 前提: 1 と同じ設定で、プロジェクト `web` のグループが `default` + 操作: `devbase env list` を `projects/web` で実行する + 結果: `=== グローバル(グループ default → nyle) (...) ===` と + `=== プロジェクト: web(グループ default → nyle) (...) ===` になり、末尾の件数の行 + (`グローバル(グループ default → nyle): N変数` / `プロジェクト(グループ default → nyle): N変数`)も同じ形になる + 検証: 新規テスト(`capsys` の出力を読む) +- [ ] 3. 前提: 1 と同じ設定 + 操作: `devbase env backend migrate --to age --dry-run` と、`--dry-run` なしの `--to age` を実行する + 結果: 移行の計画の一覧の見出しと、完了後の「サーバ上の機密はそのまま残っています」の一覧の見出しが、 + どちらも `グローバル(グループ default → nyle)` になる。この 2 つは別の関数 + (`_MigrationPlan._heading` と `cmd_env_backend_migrate` の完了表示)が出すため、両方を見る + 検証: 新規テスト 2 件 + (2026-09-22 追加。issue 本文は `test` と `list` だけを挙げるが、同じ一覧の形で + `label()` とサーバのパスを並べる箇所が `lib/devbase/commands/env_backend.py` に 2 つあり、 + 直さないと同じ食い違いが残るため) +- [ ] 4. 前提: 1 と同じ設定で、対象のグループが `kkg`(`group_aliases` に対応が無い) + 操作: `devbase env backend test` / `devbase env list` を実行する + 結果: 見出しは `グローバル(グループ kkg)` のまま。`→` は出ない + 検証: 新規テスト + +変わらないこと: + +- [ ] 5. `version: 1`(`layout: flat`)の `openbao` backend で、`env backend test` と `env list` の見出しに + グループが付かない(今と同じ文字列) + 検証: 新規テスト。`env list` の見出しが `=== グローバル (` で始まり、出力全体に `(グループ` が + 1 つも出ないことを見る。`env backend test` の参照ごとの行も同じ。あわせて既存テスト + (`tests/env/test_groups.py`・`tests/env/test_runtime.py`)が変更なしで通ること + (2026-09-22 変更。既存テストは `'=== グローバル'` の前方一致で見ており、見出しに + `(グループ default → nyle)` が付いても通ってしまうため、これだけでは条件を確かめられない) +- [ ] 6. ファイル backend(`plaintext` / `age`)の `env list` の見出しが今と同じ文字列 + 検証: 新規テスト。`openbao:` 節を残した `backend: age` の設定でも、見出しに `(グループ` が + 出ないことを見る(前提 2 の取り違えをそのまま検査する)。あわせて既存テスト + (`tests/commands/test_env_user_axis.py`)が変更なしで通ること + (2026-09-22 変更。理由は条件 5 と同じ) +- [ ] 7. `SecretRef.label()` を通して参照を出すエラー文言・警告・ログは、読み替えのあるグループでも + 読み替える**前**の名前のままである。例は `layout: flat` でグループ付きの参照を拒む例外 + (`lib/devbase/env/openbao.py` の `OpenBaoBackend._check_group`)と、置き場の + `DEVBASE_ACCOUNT_GROUP` を使わない旨の警告(`lib/devbase/env/runtime.py`)である + 検証: 新規テスト 1 件(読み替えのあるグループで、この 2 つの文言に `→` が出ないこと) +- [ ] 8. `env backend status` の `グループ: default → nyle` が今と同じ + 検証: 既存テストが変更なしで通ること + +仕様書(#188): + +- [ ] 9. `docs/specifications/secret-backend.md` の「`label()` はグループがあれば `(グループ <名前>)` を + 後ろに付ける」と「文言には読み替えの前と後を `default → nyle` の形で出す(`display_group`)」が、 + どの文言がどちらになるかを重ならない形で述べている + 検証: 設計 Pull Request と実装 Pull Request のレビュー(読んで確かめる) +- [ ] 10. 同文書の `list` の見出しの例に、読み替えのある場合(`=== グローバル(グループ default → nyle) (...) ===`)が + 加わっている + 検証: 同上 +- [ ] 11. 利用者向け文書(`docs/user/env-backend.md`、`docs/user/cli-reference/03-env.md`)の見出しの例に + 読み替えのある場合が加わっている + 検証: 同上 + +退行しないこと: + +- [ ] 12. 全体テスト(`uv run pytest tests/ -q`)が通る + 検証: 実装 Pull Request の本文に実行結果を載せる(`release/v3.7.0` を base にした Pull Request では + CI が 1 件も動かないため、手元の実行が唯一の証跡になる。#216) + +## 影響 + +| 対象 | 影響 | +| --- | --- | +| 公開インタフェース | 変わる: `group_aliases` のあるグループでのみ、`env backend test` / `env list` / `env backend migrate`(計画の一覧と `--to age` の完了表示)の見出しのグループ名が `default` から `default → nyle` になる。CHANGELOG では Fixed に書く | +| データ | 変わらない。置き場のパス・キャッシュの位置・`backend.yml` の内容は同じ | +| 既存の振る舞い | `SecretRef.label()` の既定の返り値は変えない。エラー文言・警告・ログは今のまま | +| 確定仕様 | `docs/specifications/secret-backend.md` の 2 か所の記述を書き分け、例を 1 つ足す | + +## 検証手段 + +| 項目 | 手段 | +| --- | --- | +| テスト | `uv run pytest tests/ -q`(`release/v3.7.0` を base にする Pull Request では CI が動かないため手元で行い、結果を Pull Request 本文へ載せる) | +| 静的解析 | `ruff check --select=E9,F63,F7,F82 lib` | +| 手動確認 | この端末(`group_aliases: {default: nyle}`)で `devbase env backend test` と `devbase env list --keys-only` を打ち、見出しとパスが同じグループを指すことを見る。**読み取りのみ**(前提 5) | + +## 前提とする取り決め + +| 項目 | 参照先 / 決めたこと | +| --- | --- | +| プロジェクト構造 | 参照の値と表示の文言は `lib/devbase/env/secret_store.py`、グループの読み替えの規則は `lib/devbase/env/backend_config.py`、コマンドの出力は `lib/devbase/commands/`。文言の組み立てはコマンド側へ写さない | +| コーディング規約 | 既存に合わせる(`ruff`)。`SecretRef` は `frozen=True` のまま | +| テスト戦略 | 表示の契約は単体テスト、コマンドの出力は `capsys` を使うコマンドのテスト。`DEVBASE_ROOT` は各テストが `monkeypatch.setenv` で自分で差し替える既存の流儀に合わせる(`tests/conftest.py` の autouse fixture による隔離は #217 が扱い、まだ `release/v3.7.0` に入っていない) | + +## 境界 + +| 区分 | 内容 | +| --- | --- | +| 常に行う | 手元で全体テスト、`ruff` | +| 確認してから行う | `SecretRef.label()` の引数の追加(設計 Pull Request の承認で確かめる)、確定仕様の書き分け | +| 行わない | エラー文言・ログの表示の変更、桁幅の変更、実環境への書き込み | + +## 実装計画 + +設計は [PLAN64_secret-label-group-design.md](PLAN64_secret-label-group-design.md)。 + +## 依頼(原文) + +devbasex/devbase#188 より。 + +> ## 何が起きたか +> +> `version: 2`(`group_aliases: {default: nyle}`)の端末で `devbase env backend test` を打つと、見出しが読み替え前の名前だけになる。 +> +> ```text +> グローバル(グループ default) devbase/team/nyle/global 22 変数 +> ``` +> +> `env backend status` は `グループ: default → nyle` と両方を出すのに、`SecretRef.label()` を使う見出し(`env backend test`・`env list`)は `default` だけで、隣のパス(`nyle`)と食い違って見える。 +> +> 現象は v3.6.0 でも再現する。`SecretRef.label()`(`lib/devbase/env/secret_store.py:151`)は `self.group` をそのまま埋め込み、`storage_group` も `display_group` も呼ばない。 +> +> ## 仕様書が自己矛盾している +> +> `docs/specifications/secret-backend.md` に、相反する 2 つの記述がある。 +> +> | 箇所 | 記述 | +> | --- | --- | +> | 「`label()` はグループがあれば `(グループ <名前>)` を後ろに付ける」 | 読み替え前を出す | +> | 「文言には読み替えの前と後を `default → nyle` の形で出す(`display_group`)」 | 読み替え後も出す | +> +> `list` の見出しの例は `=== グローバル(グループ with) (...) ===` で固定されているが、`with` は読み替えの無いグループなので、この例では前後が一致してしまい矛盾が表に出ない。**コードを直す前に、どちらを採るかを仕様の側で決める必要がある。** +> +> ## 修正レイヤー +> +> **現象レイヤー**: `env backend test` の見出し(`lib/devbase/commands/env_backend.py:472`)と `env list` の見出し(`lib/devbase/commands/env.py` の `cmd_env_list` / `_group_suffix`)。 +> +> **修正レイヤー**: `SecretRef.label()`(`lib/devbase/env/secret_store.py:151`)が返すグループの表示。呼び出される側の契約であり、`env list` の `_group_suffix` は「文言は `SecretRef.label()` が持ち、ここでは写さずに差分だけを取り出す」と明記して label() に従っている。見出しを 1 か所ずつ直すと、この従属関係が崩れる。 +> +> `label()` は `SecretRef` にあり `OpenBaoSettings` を知らないため、読み替えの前後を出すには表示の側へ設定を渡す必要がある。`label()` のコメントは「チーム単位の文字列は変えない(誤りの伝達や桁揃えに埋め込まれている)」と断っているので、`label()` にグループの表示だけを差し替える引数を足すか、表示用のラッパを 1 つ置くかを設計で決める。 +> +> **採る手**: 移動(`move_responsibility`)。グループの表示の責務を、呼び出し側から `label()` の契約へ寄せる。 +> +> ### 波及の範囲 +> +> `label()` は `lib/` の中で **52 か所 / 10 ファイル**から呼ばれる。大半はエラー文言とログである。 +> +> **既定の振る舞いを変えると 52 か所すべてに及ぶ。** 見出しだけを変えるなら、差し替えるのは `commands/env_backend.py:472` と `commands/env.py` の `_group_suffix` の 2 か所で足りる。どちらにするかが設計の分かれ目である。 +> +> ## 期待すること +> +> 見出しのグループ名も `status` と同じく読み替えの前後を出す(`OpenBaoSettings.display_group` を使う)。`version: 1` とファイル backend の見出しは変えない。 +> +> ## 受け入れ条件 +> +> - `group_aliases` のあるグループで `env backend test` と `env list` の見出しが読み替えの前後を出す +> - `docs/specifications/secret-backend.md` の 2 つの記述の矛盾が解け、`list` の見出しの例に読み替えのある場合(`default → nyle`)が加わる +> - `version: 1` とファイル backend の見出しが変わらない