diff --git a/CHANGELOG.md b/CHANGELOG.md index 9bd28319..392d8c68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,19 @@ - **非推奨の `container` / `ct` グループは名前解決の対象外になりました(PLAN61 / #200)。** `devbase container up ` は実在するプロジェクト名でも usage エラー(終了コード 2)です。 名前の指定は `devbase project up ` か `devbase up ` を使ってください。 +- **機密の置き場(`.env` / `age` / OpenBao)に書いた `DEVBASE_ACCOUNT_GROUP` を、`version: 1` でも + 使わなくなりました(PLAN62 / #185)。** 置き場の値は機密の合成から外れ、devbase のプロセスの + 環境変数にも dev コンテナの `environment` にも載りません。アカウントグループを決めるのは + `env` ファイル(`projects//env` / `$DEVBASE_ROOT/env`)とシェルの環境変数だけになります。 + 置き場に残っていれば、コマンドのたびに置き場ごとに 1 回、消し方(`devbase env delete + DEVBASE_ACCOUNT_GROUP` と付ける引数)を添えた警告が出ます(値は出しません)。 + + > **Note:** これまで `version: 1` で置き場の値でグループを切り替えていた端末では、ボリューム + > のグループが `env` ファイルの宣言(無ければ `default`)へ戻ります。同じグループで使い + > 続けるには `projects//env` に `DEVBASE_ACCOUNT_GROUP=` を書いてください。 +- `devbase env set DEVBASE_ACCOUNT_GROUP=...`(`-p` / `--user` / `--group` 付きも同じ)は、置き場へ + 書かずに `env` ファイルへ書くよう案内して終了コード 1 で止まります。`env import` / `env edit` と + 他のキーの `env set` は変わりません。 ### Fixed - **`devbase build --help` / `-h` がビルドを始めてしまう**のを直しました(PLAN61 / #196)。 diff --git a/docs/user/cli-reference/03-env.md b/docs/user/cli-reference/03-env.md index 82e09717..3b295cd0 100644 --- a/docs/user/cli-reference/03-env.md +++ b/docs/user/cli-reference/03-env.md @@ -99,6 +99,9 @@ devbase env set KEY=VALUE [-p] [--user] [--group NAME] | `--user` | 個人単位の置き場に設定(サーバ backend のみ。[機密の保存先を選ぶ](../env-backend.md)) | | `--group NAME` | 対象のグループの置き場に設定(グループ別の置き場のみ。`-p` と組み合わせるときはプロジェクトのグループと同じ置き場に限る) | +`DEVBASE_ACCOUNT_GROUP` は書けません(どのオプションでも終了コード 1)。アカウントグループは +`projects//env` か `$DEVBASE_ROOT/env` に書きます([環境変数ガイド](../environment-variables.md#機密の置き場には書けない))。 + ```bash # グローバルに設定 devbase env set ANTHROPIC_API_KEY=sk-xxx diff --git a/docs/user/env-backend.md b/docs/user/env-backend.md index 3417346d..07b75bd7 100644 --- a/docs/user/env-backend.md +++ b/docs/user/env-backend.md @@ -225,9 +225,11 @@ OpenBao の KV v2 にはコメント・空行の置き場がありません。`d なります。プロジェクトの外(`$DEVBASE_ROOT` など)では 2 → 3 で決まります。 **機密の置き場に書いた `DEVBASE_ACCOUNT_GROUP` はグループの決定に使われません**(置き場を -決める値をその置き場から読むことになるため)。置き場には書かないでください。書くと -コンテナを起動するプロセスの環境変数にだけ載り、ボリュームのグループが変わって -[`up` / `scale` が止まる](#up--scale-がグループの食い違いで止まったとき)ことがあります。 +決める値をその置き場から読むことになるため)。置き場の値は機密の合成から外れ、コンテナを +起動するプロセスの環境変数にもコンテナにも載りません(`version: 1` でも同じ)。置き場に +残っていればコマンドのたびに警告が 1 回出るので、そこに書かれた +`devbase env delete DEVBASE_ACCOUNT_GROUP ...` で消してください。`devbase env set` は +このキーを置き場へ書かずに終了コード 1 で止まります([環境変数ガイド](environment-variables.md#機密の置き場には書けない))。 `default` をボリューム名(`devbase_home_default`)はそのままに、置き場の上だけ別の名前で 扱うには `group_aliases` を使います。`default: nyle` なら、グループを宣言していない @@ -407,9 +409,9 @@ Error: ボリュームと機密のアカウントグループが食い違うた | 環境変数で指定したグループ(例: `kkg`) | `projects//env` に `DEVBASE_ACCOUNT_GROUP=kkg` を書く。プロジェクトの `env` はラッパーが読み込むため、両方が揃う | | `env` ファイルで決まるグループ | シェルの環境変数を外す(`unset DEVBASE_ACCOUNT_GROUP`。シェルの設定ファイルで `export` していればその行も消す) | -どちらでもなく、機密の置き場に `DEVBASE_ACCOUNT_GROUP` を書いていたときは、その行を -`devbase env delete DEVBASE_ACCOUNT_GROUP`(書いた置き場に合わせて `-p` / `--user` / `--group`)で -消してください。 +機密の置き場に書いた `DEVBASE_ACCOUNT_GROUP` はこの食い違いを起こしません(置き場の値は +プロセスの環境変数へ載らないため)。置き場に残っていると別の警告が出るので、そこに書かれた +`devbase env delete DEVBASE_ACCOUNT_GROUP ...` で消してください。 `version: 1` の設定では、この検査は行いません。 diff --git a/docs/user/environment-variables.md b/docs/user/environment-variables.md index 2674933c..58f35cd8 100644 --- a/docs/user/environment-variables.md +++ b/docs/user/environment-variables.md @@ -260,6 +260,21 @@ DEVBASE_ACCOUNT_GROUP=kkg [コンテナ運用ガイド](container-operations.md)、Google 認証の手順は [Google 認証ガイド](google-auth.md) を参照してください。 +### 機密の置き場には書けない + +`DEVBASE_ACCOUNT_GROUP` を決めるのは `env` ファイル(`projects//env` / +`$DEVBASE_ROOT/env`)とシェルの環境変数だけです。機密の置き場(`.env` / `age` / OpenBao の +どれでも、`version: 1` でも)に書いた値は使われず、コンテナへも渡りません。置き場に残って +いれば、devbase のコマンドを打つたびに次の警告が 1 回出ます(値は出しません)。 + +```text +Warning: 機密の置き場(グローバル)にある DEVBASE_ACCOUNT_GROUP は使いません。アカウントグループは env ファイル(projects//env・$DEVBASE_ROOT/env)で決まります。消すには: devbase env delete DEVBASE_ACCOUNT_GROUP +``` + +警告に書かれたとおり `devbase env delete DEVBASE_ACCOUNT_GROUP`(置き場に合わせて `-p` / +`--user` / `--group` が付きます)で消してください。`devbase env set DEVBASE_ACCOUNT_GROUP=...` は +置き場へ書かずに終了コード 1 で止まります。`env` ファイルに書いてください。 + ### 機密の置き場もグループで分ける(OpenBao) 機密の保存先に OpenBao を使い、グループ別の置き場(`secrets/backend.yml` の `version: 2`)を @@ -274,7 +289,7 @@ DEVBASE_ACCOUNT_GROUP=kkg 2. `$DEVBASE_ROOT/env` 3. どちらにも無ければ `default` -**機密の置き場(`devbase env set` で入れた値)に書いた `DEVBASE_ACCOUNT_GROUP` は使われません。** +**機密の置き場に書いた `DEVBASE_ACCOUNT_GROUP` は使われません**([機密の置き場には書けない](#機密の置き場には書けない))。 グループはここに挙げたファイルに書いてください。シェルの環境変数で渡したグループとファイルで 決まるグループが食い違うと、`devbase up` / `scale` は起動せずに止まります(直し方は [`up` / `scale` がグループの食い違いで止まったとき](env-backend.md#up--scale-がグループの食い違いで止まったとき))。 diff --git a/issues/PLAN62_inject-account-group.md b/issues/PLAN62_inject-account-group.md index 48ebdaf9..d757ce12 100644 --- a/issues/PLAN62_inject-account-group.md +++ b/issues/PLAN62_inject-account-group.md @@ -57,25 +57,34 @@ ## 受け入れ条件 -- [ ] 1. 前提: チーム共通の機密(`version: 1`・`plaintext` / `age` backend)に `DEVBASE_ACCOUNT_GROUP=kkg` があり、 +- [x] 1. 前提: チーム共通の機密(`version: 1`・`plaintext` / `age` backend)に `DEVBASE_ACCOUNT_GROUP=kkg` があり、 プロセスの環境変数に `DEVBASE_ACCOUNT_GROUP` が無い 操作: `runtime.inject(root, project)` を呼ぶ 結果: プロセスの環境変数に `DEVBASE_ACCOUNT_GROUP` が載らず、`resolve_account_group()` は `default` を返す -- [ ] 2. 前提: 1 と同じ置き場で、プロセスの環境変数に `DEVBASE_ACCOUNT_GROUP=with` がある(`env` ファイル由来) + 検証: `tests/env/test_runtime.py::test_inject_does_not_put_the_stores_account_group_into_the_environment[age|plaintext]` +- [x] 2. 前提: 1 と同じ置き場で、プロセスの環境変数に `DEVBASE_ACCOUNT_GROUP=with` がある(`env` ファイル由来) 操作: `runtime.inject(root, project)` を呼ぶ 結果: プロセスの環境変数は `with` のまま変わらない -- [ ] 3. 個人共通・プロジェクトのチーム・プロジェクトの個人の置き場にあっても、1・2 と同じになる -- [ ] 4. 置き場にあったときは、キー名と置き場の種類・消し方を含む警告が出て、値は出ない。 + 検証: `tests/env/test_runtime.py::test_inject_keeps_the_environments_account_group[age|plaintext]`、`::test_inject_keeps_the_projects_env_declaration` +- [x] 3. 個人共通・プロジェクトのチーム・プロジェクトの個人の置き場にあっても、1・2 と同じになる + 検証: `tests/env/test_runtime.py::test_every_store_layer_is_dropped_from_the_environment[4 層]`、`::test_every_store_layer_loses_to_the_environment[4 層]` +- [x] 4. 置き場にあったときは、キー名と置き場の種類・消し方を含む警告が出て、値は出ない。 1 回の CLI 起動で同じ置き場について 2 回以上出ない -- [ ] 5. `resolve()` の結果(`SecretEnv.names` / `values`)に `DEVBASE_ACCOUNT_GROUP` が含まれない。 + 検証: `tests/env/test_runtime.py::test_warns_once_with_the_store_and_how_to_delete[4 種]`、`::test_warning_for_a_grouped_reference_names_the_group`、`::test_warning_for_a_grouped_global_reference_still_adds_the_group`、`::test_warning_is_not_repeated_across_stores_and_release`、`::test_each_reference_warns_separately`、`::test_empty_value_still_warns_and_no_key_does_not` +- [x] 5. `resolve()` の結果(`SecretEnv.names` / `values`)に `DEVBASE_ACCOUNT_GROUP` が含まれない。 したがって dev コンテナの `environment` へ置き場の値は列挙されない -- [ ] 6. `layout: group` で、置き場(`$DEVBASE_ROOT/env` にも `projects//env` にもグループの宣言が無い + 検証: `tests/env/test_runtime.py::test_resolve_never_lists_the_account_group`、`::test_child_env_does_not_carry_the_stores_account_group` +- [x] 6. `layout: group` で、置き場(`$DEVBASE_ROOT/env` にも `projects//env` にもグループの宣言が無い プロジェクトの、共通の機密)に `DEVBASE_ACCOUNT_GROUP=kkg` があっても、`devbase up` の前の グループの食い違いの検査で止まらない -- [ ] 7. `devbase env set DEVBASE_ACCOUNT_GROUP=kkg`(`-p` / `--user` / `--group` 付きも同じ)は、置き場へ書かずに + 検証: `tests/commands/test_container_up_order.py::test_store_account_group_does_not_stop_the_group_check` +- [x] 7. `devbase env set DEVBASE_ACCOUNT_GROUP=kkg`(`-p` / `--user` / `--group` 付きも同じ)は、置き場へ書かずに `env` ファイルへ書くよう案内して終了コード 1 -- [ ] 8. 他のキーの注入(重ね順・注入の履歴・`clear_injected` による巻き戻し)の既存テストが変更なしで通る -- [ ] 9. 全体テスト(`uv run pytest tests/`)が通る + 検証: `tests/commands/test_env_account_group.py::test_set_refuses_the_account_group_without_opening_the_store[6 組]`、`::test_set_refuses_the_account_group_without_creating_the_file[global|project]`、`::test_set_refuses_the_account_group_with_surrounding_spaces`、`::test_set_of_another_key_still_writes` +- [x] 8. 他のキーの注入(重ね順・注入の履歴・`clear_injected` による巻き戻し)の既存テストが変更なしで通る + 検証: `tests/env/test_runtime.py`(既存 38 件は追記のみで本文の変更なし)・`tests/env/test_runtime_store.py`・`tests/cli/test_secret_injection.py`(変更なし)が全体テストで通過 +- [x] 9. 全体テスト(`uv run pytest tests/`)が通る + 検証: `uv run --locked pytest -q tests/` → 2717 passed(exit 0)、`ruff check --select=E9,F63,F7,F82 lib` → All checks passed(exit 0) ## 影響 @@ -107,3 +116,71 @@ | 常に行う | 全体テスト | | 確認してから行う | 前提 1(`version: 1` の挙動を変えること。設計 Pull Request の承認で確かめる) | | 行わない | 置き場の値の自動削除、他のキーの扱いの変更 | + +## 実装計画 + +設計は [PLAN62_inject-account-group-design.md](PLAN62_inject-account-group-design.md)。タスクはその「構成要素」と「テスト設計」から導く。どのタスクも失敗するテスト → 通す最小実装 → 整理の順で進める。 + +### 修正対象 + +- `lib/devbase/env/runtime.py`(`_warned_account_group_refs` / `_without_account_group` の新設、`resolve` の変更) +- `lib/devbase/commands/env.py`(`cmd_env_set` の拒否) +- `lib/devbase/env/keys.py`(コメント) +- `tests/env/test_runtime.py`(受け入れ条件 1〜5) +- `tests/commands/test_container_up_order.py`(受け入れ条件 6) +- `tests/commands/test_env_account_group.py`(新設。受け入れ条件 7) +- `docs/user/environment-variables.md` / `docs/user/env-backend.md` / `docs/user/cli-reference/03-env.md` / `CHANGELOG.md` + +### Task 1: [x] `resolve` が 4 つの置き場の `DEVBASE_ACCOUNT_GROUP` を合成しない + +- **対象ファイル:** `lib/devbase/env/runtime.py`、`tests/env/test_runtime.py` +- **変更内容:** `_without_account_group(ref, data)` を新設し、`resolve` の 4 つの `store.load` の直後に通す(決定 1・6。重ね順 3 は変えない)。docstring に理由を書き足す +- **満たす受け入れ条件:** 1、2、3、5 +- **進め方:** age / plaintext の `store` と `_FourLayerStore`(4 置き場を parametrize)で、`inject` 後の `os.environ` と `resolve_account_group()`、`resolve` の `names` / `global_names` / `project_names` / `values` を確かめるテストを先に書く → `resolve` を変えて通す + +### Task 2: [x] 外したことを置き場ごとに 1 回だけ警告する + +- **対象ファイル:** `lib/devbase/env/runtime.py`、`tests/env/test_runtime.py` +- **変更内容:** モジュール変数 `_warned_account_group_refs`(`SecretRef` の集合)を新設し、`_without_account_group` がキーを見つけた参照について 1 回だけ `logger.warning` を出す。文言は設計「警告」の表どおり(`label()`、`-p` / `--user` / `--group` の引数、プロジェクトの参照なら実行場所)。値は出さない +- **満たす受け入れ条件:** 4 +- **進め方:** caplog で 4 種の参照とグループ付きの参照の文言、`release_store` と別 `store` をまたいだ重複抑止、値が出ないことを確かめるテストを先に書く → 集合と警告を足して通す + +### Task 3: [x] `layout: group` で置き場の値があっても `up` の検査が止まらない + +- **対象ファイル:** `tests/commands/test_container_up_order.py` +- **変更内容:** 偽の OpenBao(`layout: group`、`default → nyle`)の `team/nyle/global` に `DEVBASE_ACCOUNT_GROUP=kkg` を置き、`runtime.inject(root, 'web')` の後に `container._check_group_consistency()` が True になることを固定する(決定 8)。実装の変更は Task 1 に含まれる +- **満たす受け入れ条件:** 6 +- **進め方:** Task 1 の前に書けば失敗し、Task 1 で通る。ここでは順序どおりに呼ぶテストを足して通ることを確かめる(Task 1 の実装で満たされるため追加の実装は無い) + +### Task 4: [x] `env set DEVBASE_ACCOUNT_GROUP=...` を置き場を開く前に拒否する + +- **対象ファイル:** `lib/devbase/commands/env.py`、`tests/commands/test_env_account_group.py`(新設) +- **変更内容:** `cmd_env_set` でキー名が `keys.DEVBASE_ACCOUNT_GROUP` なら、`_open_target_env` より前に error ログを出して 1 を返す(決定 7)。文言は設計「コマンド `env set`」の表どおり +- **満たす受け入れ条件:** 7 +- **進め方:** 偽の OpenBao で `project` / `user` / `group` の各組み合わせが 1 で POST が無いこと、ファイル backend で `.env` が作られないこと、error ログに `env` ファイルの案内があることを確かめるテストを先に書く → 早期 return を足して通す + +### Task 5: [x] 文書・コメント・CHANGELOG を新しい挙動に合わせる + +- **対象ファイル:** `lib/devbase/env/keys.py`、`docs/user/environment-variables.md`、`docs/user/env-backend.md`、`docs/user/cli-reference/03-env.md`、`CHANGELOG.md` +- **変更内容:** 設計「構成要素」の表のとおり。置き場の値は `version: 1` でも使われず警告が出ること、`env set` で書けないこと、`up` / `scale` の食い違いの直し方から「置き場に書いていたとき」の段落を書き替える +- **満たす受け入れ条件:** (文書。受け入れ条件には無いが要求の対象範囲「含む」) +- **進め方:** 文書のためテスト駆動を適用しない + +### Task 6: [x] 全体テストと静的解析 + +- **対象ファイル:** なし +- **変更内容:** `uv run --locked pytest -q tests/` と `ruff check --select=E9,F63,F7,F82 lib` を実行し、終了コードを記録する +- **満たす受け入れ条件:** 8、9 +- **進め方:** 既存テスト(`tests/env/test_runtime.py`・`tests/env/test_runtime_store.py`・`tests/cli/test_secret_injection.py`)を変更していないことを `git diff --stat` で確かめる + +### リスクと対処 + +| リスク | 対処 | +| --- | --- | +| `resolve` は `inject` / `child_env` / コンテナの列挙の共通経路で、外し方を誤ると他のキーの重ね順が変わる | 読み取りの直後にキーを除くだけにし、重ね順の既存テスト(Task 6)を変更なしで通す | +| 警告の集合がテスト間で漏れ、重複抑止のテストが順序に依存する | 各テストの先頭で `monkeypatch.setattr(runtime, '_warned_account_group_refs', set())` | +| テストが実環境の `DEVBASE_ROOT`(シェルが持つ)を継承し、実の secrets / OpenBao に触る | 新しいテストは必ず `DEVBASE_ROOT` を tmp へ向け、偽の OpenBao / `_FourLayerStore` を使う | + +### 切り戻し手順 + +- `runtime.py` と `commands/env.py` の変更を戻せば元の挙動に戻る。置き場の値は消していないため、データの巻き戻しは無い diff --git a/lib/devbase/commands/env.py b/lib/devbase/commands/env.py index 6160c758..c32daa85 100644 --- a/lib/devbase/commands/env.py +++ b/lib/devbase/commands/env.py @@ -746,7 +746,14 @@ def _format_value(key: str, value: str, reveal: bool) -> str: def cmd_env_set(devbase_root: Path, assignment: str, project: bool = False, user: bool = False, group: Optional[str] = None) -> int: - """変数を設定する""" + """変数を設定する + + ``DEVBASE_ACCOUNT_GROUP`` は置き場を開く前に拒否する (PLAN62 決定 7)。置き場の値は + アカウントグループの決定に使われない (:func:`devbase.env.runtime.resolve` が合成しない) + ため、書いても使われない値を新たに作らない。:func:`_open_target_env` より前に返すので、 + 書き込み・ファイルの作成・書き込みのための読み出し (``fresh``) も、``--group`` の検査も + 起きない。 + """ if '=' not in assignment: logger.error("形式: devbase env set KEY=VALUE") return 1 @@ -759,6 +766,13 @@ def cmd_env_set(devbase_root: Path, assignment: str, project: bool = False, logger.error("キー名が空です") return 1 + if key == keys.DEVBASE_ACCOUNT_GROUP: + logger.error( + "%s は機密の置き場へは書けません(置き場の値はアカウントグループの決定に" + "使われません)。projects//env か $DEVBASE_ROOT/env に書いてください", + keys.DEVBASE_ACCOUNT_GROUP) + return 1 + env_file, rc = _open_target_env(devbase_root, project, user=user, group=group) if env_file is None: return rc diff --git a/lib/devbase/env/keys.py b/lib/devbase/env/keys.py index 85f845aa..04aa72a7 100644 --- a/lib/devbase/env/keys.py +++ b/lib/devbase/env/keys.py @@ -52,6 +52,8 @@ def gcp_credentials_key(profile: str) -> str: # 使用する Google / AWS アカウントの単位。グループごとに devbase_home_ を # 作り、コンテナへ /persistent/group としてマウントする。未設定なら `default`。 # プロジェクト env / グローバル env に手書きする devbase 動作設定。 +# 機密の置き場 (.env / age / OpenBao) にあっても注入しない (PLAN62: env/runtime.py が +# 合成から外して警告し、`env set` は拒否する)。 # 詳細: docs/user/environment-variables.md DEVBASE_ACCOUNT_GROUP = "DEVBASE_ACCOUNT_GROUP" diff --git a/lib/devbase/env/runtime.py b/lib/devbase/env/runtime.py index 000f1781..e628295d 100644 --- a/lib/devbase/env/runtime.py +++ b/lib/devbase/env/runtime.py @@ -16,14 +16,25 @@ import os from dataclasses import dataclass, field from pathlib import Path -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, Dict, List, Optional, Set, Tuple +from devbase.env import keys from devbase.env.secret_store import SecretRef, SecretStore from devbase.env.store import EnvFile from devbase.log import get_logger logger = get_logger(__name__) +#: 機密の置き場に :data:`~devbase.env.keys.DEVBASE_ACCOUNT_GROUP` があることを警告した +#: 参照の集合 (PLAN62 決定 4)。 +#: +#: 1 回の起動で :func:`resolve` は何度も呼ばれる (dispatch 前の注入・各コマンドの +#: ``_inject_secrets``・``env exec``)。同じ置き場について警告を 1 回にするため、出した参照を +#: ここに控える。:class:`SecretStore` に持たせないのは、ストアが :func:`release_store` で +#: 捨てられる (``up`` の中の ``env init`` の後など) たびに警告が出直すため。プロセスが終わる +#: まで持つ。 +_warned_account_group_refs: Set[SecretRef] = set() + # --------------------------------------------------------------------------- # SecretStore の持ち回り (PLAN55) @@ -171,6 +182,46 @@ def _project_env_overrides(devbase_root: Path, project: str) -> Dict[str, str]: return {key: os.environ[key] for key in keys if key in os.environ} +def _account_group_delete_hint(ref: SecretRef) -> str: + """置き場 ``ref`` の ``DEVBASE_ACCOUNT_GROUP`` を消す ``env delete`` の引数と実行場所 (PLAN62 決定 5)。 + + ``env delete`` の宛先は ``-p`` (適用範囲) / ``--user`` (持ち主) / ``--group`` (グループ) の + 3 軸で決まり、参照の 3 つのフィールドと 1 対 1 に対応する。グループを持つ参照には共通の + 参照でも ``--group`` を付ける。省くと ``env delete`` は実行した場所のグループを宛先にし、 + 警告を出した置き場と違う置き場を指すことがある。``-p`` はプロジェクトのディレクトリで + しか使えないため、実行場所を添える。 + """ + args = '' + if ref.kind == 'project': + args += ' -p' + if ref.is_user: + args += ' --user' + if ref.group: + args += f' --group {ref.group}' + where = f'(projects/{ref.name} で実行)' if ref.kind == 'project' else '' + return f'{args}{where}' + + +def _without_account_group(ref: SecretRef, data: Dict[str, str]) -> Dict[str, str]: + """置き場から読んだ ``data`` から ``DEVBASE_ACCOUNT_GROUP`` を除いた辞書を返す (PLAN62)。 + + キーが無ければ ``data`` をそのまま返す。受け取った辞書は変えない。あれば、その置き場 + (``ref``) についてまだ警告していないときだけ、無視したことと消し方を警告で知らせる + (値は出さない。決定 3・4)。 + """ + if keys.DEVBASE_ACCOUNT_GROUP not in data: + return data + if ref not in _warned_account_group_refs: + _warned_account_group_refs.add(ref) + logger.warning( + "機密の置き場(%s)にある %s は使いません。アカウントグループは env ファイル" + "(projects//env・$DEVBASE_ROOT/env)で決まります。" + "消すには: devbase env delete %s%s", + ref.label(), keys.DEVBASE_ACCOUNT_GROUP, keys.DEVBASE_ACCOUNT_GROUP, + _account_group_delete_hint(ref)) + return {key: value for key, value in data.items() if key != keys.DEVBASE_ACCOUNT_GROUP} + + def resolve(devbase_root: Path, project: Optional[str] = None, *, store: Optional[SecretStore] = None) -> SecretEnv: """機密を合成して返す。 @@ -201,6 +252,15 @@ def resolve(devbase_root: Path, project: Optional[str] = None, (PLAN56)。グループ別の置き場 (``layout: group``) では、``project`` のグループの 置き場だけを読み、他のグループのパスへは要求しない。それ以外の設定ではグループが ``None`` で、参照は今と同じ値になる (決定 5)。 + + 4 つの置き場にある ``DEVBASE_ACCOUNT_GROUP`` は合成しない (PLAN62 決定 1・2)。 + アカウントグループ (ボリューム ``devbase_home_`` と ``layout: group`` の置き場) + を決める値は ``env`` ファイルとシェルの環境変数からだけ来るべきで、置き場の値を + 載せるとプロセスの環境変数を読む :func:`~devbase.volume.manager.resolve_account_group` + が置き場の値でグループを変えてしまう。``inject`` / :func:`child_env` / コンテナへ列挙する + 変数名はどれもこの結果から作られるため、読み取りの直後の 1 か所で外す。重ね順 3 + (``projects//env``) は変えない: ``names`` に無いキーは ``values`` に採らないため、 + ``env`` ファイルの値も ``values`` には現れず、プロセスの環境変数に元からある値が残る。 """ root = Path(devbase_root) store = store if store is not None else store_for(root) @@ -209,8 +269,11 @@ def resolve(devbase_root: Path, project: Optional[str] = None, ref_group = getattr(store, 'ref_group', None) group = ref_group(project) if callable(ref_group) else None - team_global = store.load(SecretRef.for_global(group=group)) - user_global = store.load(SecretRef.for_global(owner='user', group=group)) + def load(ref: SecretRef) -> Dict[str, str]: + return _without_account_group(ref, store.load(ref)) + + team_global = load(SecretRef.for_global(group=group)) + user_global = load(SecretRef.for_global(owner='user', group=group)) global_names = list(dict.fromkeys([*team_global, *user_global])) project_names: List[str] = [] @@ -219,8 +282,8 @@ def resolve(devbase_root: Path, project: Optional[str] = None, if project: merged.update(_project_env_overrides(root, project)) - team_project = store.load(SecretRef.for_project(project, group=group)) - user_project = store.load(SecretRef.for_project(project, owner='user', group=group)) + team_project = load(SecretRef.for_project(project, group=group)) + user_project = load(SecretRef.for_project(project, owner='user', group=group)) merged.update(team_project) merged.update(user_project) project_names = list(dict.fromkeys([*team_project, *user_project])) diff --git a/tests/commands/test_container_up_order.py b/tests/commands/test_container_up_order.py index b616a595..2cf49c2f 100644 --- a/tests/commands/test_container_up_order.py +++ b/tests/commands/test_container_up_order.py @@ -11,6 +11,7 @@ from __future__ import annotations +import os from pathlib import Path import pytest @@ -342,3 +343,38 @@ def broken_defaults(compose_file, environ=None): assert calls == [] # down も up も呼ばれていない assert container._SCALE_COMPOSE_FILE.read_text() == OLD_COMPOSE assert not Path(f'{container._SCALE_COMPOSE_FILE}.prev').exists() + + +# --------------------------------------------------------------------------- +# 機密の置き場の DEVBASE_ACCOUNT_GROUP は up の検査を止めない (PLAN62) +# --------------------------------------------------------------------------- + +def test_store_account_group_does_not_stop_the_group_check(openbao_root, openbao, + monkeypatch, caplog): + """受け入れ条件 6: dispatch 前の注入 → 食い違いの検査 の順に呼んでも True。 + + ``projects/web`` にも ``$DEVBASE_ROOT/env`` にも宣言が無く、共通の機密 (``team/nyle/global``) + に ``DEVBASE_ACCOUNT_GROUP=kkg`` がある。注入が置き場の値をプロセスへ載せると、ボリュームは + ``kkg``・機密は ``default`` で止まっていた。 + """ + from tests.conftest import configure_openbao + from devbase.env import runtime + + root = openbao_root + configure_openbao(root, openbao, layout='group', group_aliases={'default': 'nyle'}) + openbao.put('team/nyle/global', {'DEVBASE_ACCOUNT_GROUP': 'kkg', 'TOKEN': 't'}) + monkeypatch.setenv('DEVBASE_ROOT', str(root)) + monkeypatch.setenv('PWD', str(root / 'projects' / 'web')) + monkeypatch.delenv('DEVBASE_ACCOUNT_GROUP', raising=False) + monkeypatch.delenv('TOKEN', raising=False) + monkeypatch.setattr(runtime, '_warned_account_group_refs', set()) + + try: + runtime.inject(root, 'web') + + assert container._check_group_consistency() is True + assert 'DEVBASE_ACCOUNT_GROUP' not in os.environ + assert os.environ['TOKEN'] == 't' + assert '食い違う' not in caplog.text + finally: + runtime.clear_injected() diff --git a/tests/commands/test_env_account_group.py b/tests/commands/test_env_account_group.py new file mode 100644 index 00000000..304223a7 --- /dev/null +++ b/tests/commands/test_env_account_group.py @@ -0,0 +1,86 @@ +"""``devbase env set DEVBASE_ACCOUNT_GROUP=...`` は置き場へ書かずに拒否する (PLAN62 受け入れ条件 7)""" + +from __future__ import annotations + +import logging + +import pytest + +from devbase.commands import env as env_cmd +from devbase.env import agekeys, keys + + +ACCOUNT_GROUP = keys.DEVBASE_ACCOUNT_GROUP + + +@pytest.fixture +def bao_root(openbao_root, monkeypatch): + """偽の OpenBao を backend にした DEVBASE_ROOT (projects/web で実行)""" + monkeypatch.setenv('DEVBASE_ROOT', str(openbao_root)) + monkeypatch.setenv('PWD', str(openbao_root / 'projects' / 'web')) + return openbao_root + + +@pytest.fixture +def file_root(tmp_path, monkeypatch): + """backend 未設定 (平文) の DEVBASE_ROOT (projects/web で実行)""" + (tmp_path / 'projects' / 'web').mkdir(parents=True) + monkeypatch.setenv('DEVBASE_ROOT', str(tmp_path)) + monkeypatch.setenv(agekeys.KEY_FILE_ENV, str(tmp_path / 'age' / 'keys.txt')) + monkeypatch.setenv('HOME', str(tmp_path / 'home')) + monkeypatch.setenv('PWD', str(tmp_path / 'projects' / 'web')) + monkeypatch.chdir(tmp_path) + agekeys.generate_key_file() + return tmp_path + + +def errors(caplog) -> str: + return '\n'.join(r.getMessage() for r in caplog.records if r.levelno >= logging.ERROR) + + +TARGETS = [ + dict(), + dict(project=True), + dict(user=True), + dict(project=True, user=True), + dict(group='kkg'), + dict(project=True, group='kkg'), +] + + +@pytest.mark.parametrize('kwargs', TARGETS, ids=lambda kw: ' '.join(kw) or 'global') +def test_set_refuses_the_account_group_without_opening_the_store(bao_root, openbao, + caplog, kwargs): + """どの宛先でも 1。ログインも書き込みも要求しない""" + rc = env_cmd.cmd_env_set(bao_root, f'{ACCOUNT_GROUP}=kkg', **kwargs) + + assert rc == 1 + assert openbao.requests_of('POST') == [] + assert openbao.received == [] + text = errors(caplog) + assert ACCOUNT_GROUP in text + assert 'projects//env' in text and '$DEVBASE_ROOT/env' in text + + +@pytest.mark.parametrize('kwargs', [dict(), dict(project=True)], + ids=['global', 'project']) +def test_set_refuses_the_account_group_without_creating_the_file(file_root, caplog, kwargs): + """ファイル backend では .env を作らない""" + rc = env_cmd.cmd_env_set(file_root, f'{ACCOUNT_GROUP}=kkg', **kwargs) + + assert rc == 1 + assert not (file_root / '.env').exists() + assert not (file_root / 'projects' / 'web' / '.env').exists() + assert not (file_root / 'secrets').exists() + assert ACCOUNT_GROUP in errors(caplog) + + +def test_set_refuses_the_account_group_with_surrounding_spaces(file_root): + assert env_cmd.cmd_env_set(file_root, f' {ACCOUNT_GROUP} = kkg') == 1 + assert not (file_root / '.env').exists() + + +def test_set_of_another_key_still_writes(file_root): + """他のキーの env set は変わらない""" + assert env_cmd.cmd_env_set(file_root, 'TOKEN=t') == 0 + assert 'TOKEN=t' in (file_root / '.env').read_text() diff --git a/tests/env/test_runtime.py b/tests/env/test_runtime.py index 5657b449..5fcb40fa 100644 --- a/tests/env/test_runtime.py +++ b/tests/env/test_runtime.py @@ -2,13 +2,17 @@ from __future__ import annotations +import logging import os +from pathlib import Path import pyrage import pytest -from devbase.env import runtime +from devbase.env import keys, runtime from devbase.env.secret_store import SecretRef, SecretStore +from devbase.errors import DevbaseError +from devbase.volume.manager import resolve_account_group @pytest.fixture @@ -155,6 +159,65 @@ def test_inject_puts_values_into_the_given_environ(root, store): assert resolved.names == ['TOKEN'] +class _GlobalOkProjectFailsStore: + """共通機密は取れるが、プロジェクト機密の取得で例外を送出する店。 + + 重ね順どおり共通 (``global``) を先に読み、プロジェクト (``project``) の読み取りで + :class:`DevbaseError` を投げることで「共通の取得後にプロジェクトの取得が失敗する」 + 経路を再現する。``runtime`` の内部関数は置き換えず、``store`` 引数で渡す。 + """ + + def load(self, ref): + if ref.kind == 'project': + raise DevbaseError('project secrets unavailable') + return {'TOKEN': 'new-secret'} + + +class _GlobalOnlyStore: + """共通機密だけを持つ正常な店 (事前注入で ``TOKEN=old-secret`` を載せる用)""" + + def load(self, ref): + if ref.kind == 'project': + return {} + return {'TOKEN': 'old-secret'} + + +@pytest.mark.parametrize('preinjected, cleared_after_failure', [ + (False, []), + (True, ['TOKEN']), +]) +def test_inject_failing_project_leaves_target_and_history_intact( + root, preinjected, cleared_after_failure): + """現状固定: 共通機密の取得後にプロジェクト機密の取得が失敗する経路。 + + 現状は例外をそのまま伝播し、対象マッピングも既存の復元履歴も変更しない。 + - 初回注入では対象は変わらず、``clear_injected`` は空を返す。 + - 別の正常な店で ``TOKEN=old-secret`` を注入済みの場合は、対象はその失敗前の + 状態のまま (``TOKEN=old-secret``) で、``clear_injected`` は ``TOKEN`` を注入前の + ``TOKEN=shell`` へ戻す。 + いずれも ``TOKEN=shell`` と無関係なキーが残る (private な履歴には触れない)。 + """ + (root / 'projects' / 'api').mkdir() + target = {'TOKEN': 'shell', 'UNRELATED': 'keep'} + + if preinjected: + runtime.inject(root, 'web', environ=target, store=_GlobalOnlyStore()) + assert target['TOKEN'] == 'old-secret' + + before = dict(target) + with pytest.raises(DevbaseError): + runtime.inject(root, 'web', environ=target, store=_GlobalOkProjectFailsStore()) + + # 失敗した注入は対象を変えない (共通の値も載せない) + assert target == before + + cleared = runtime.clear_injected(target) + + assert cleared == cleared_after_failure + # どちらの経路でも注入前の値と無関係なキーが残る + assert target == {'TOKEN': 'shell', 'UNRELATED': 'keep'} + + # --------------------------------------------------------------------------- # 注入の解除 (プロジェクト切替時の残留対策) # --------------------------------------------------------------------------- @@ -474,6 +537,34 @@ def test_invalid_utf8_project_env_preserves_secret_values_and_origins(root, monk assert set(resolved.names) == {'TOKEN', 'USER_GLOBAL', 'PROJECT_ONLY', 'USER_PROJECT'} +def test_unreadable_project_env_preserves_secret_values_and_origins( + root, monkeypatch, caplog): + """現状固定: 読み取り不能な設定の上書きを無視し、機密と由来を保持する。""" + env_path = root / 'projects' / 'web' / 'env' + env_path.write_text('TOKEN=override\n') + monkeypatch.setenv('TOKEN', 'override') + store = _FourLayerStore(_layers( + team_global={'TOKEN': 'secret'}, team_web={'PROJECT_ONLY': 'p'})) + read_bytes = Path.read_bytes + + def read_with_permission_error(path): + if path == env_path: + raise PermissionError('project env is unreadable') + return read_bytes(path) + + monkeypatch.setattr(Path, 'read_bytes', read_with_permission_error) + + with caplog.at_level(logging.WARNING, logger=runtime.__name__): + resolved = runtime.resolve(root, 'web', store=store) + + assert resolved.values == {'TOKEN': 'secret', 'PROJECT_ONLY': 'p'} + assert set(resolved.global_names) == {'TOKEN'} + assert set(resolved.project_names) == {'PROJECT_ONLY'} + assert set(resolved.names) == {'TOKEN', 'PROJECT_ONLY'} + assert any(record.levelno == logging.WARNING and str(env_path) in record.getMessage() + for record in caplog.records) + + def test_file_backends_resolve_exactly_as_before(root, store): """個人単位の参照を持たない backend では、結果が 2 層のときと同じ""" store.age.save(GLOBAL, {'TOKEN': 'global', 'ONLY_GLOBAL': 'g'}) @@ -548,3 +639,218 @@ def test_resolve_with_the_group_layout_requests_only_the_group_paths(openbao_roo assert sorted(r.kv_path for r in openbao.requests_of('GET')) == sorted([ 'team/with/global', 'users/member01/with/global', 'team/with/projects/web', 'users/member01/with/projects/web']) + + +# --------------------------------------------------------------------------- +# 機密の置き場の DEVBASE_ACCOUNT_GROUP は合成しない (PLAN62) +# --------------------------------------------------------------------------- + +ACCOUNT_GROUP = keys.DEVBASE_ACCOUNT_GROUP + + +@pytest.fixture +def account_group_root(root, monkeypatch): + """DEVBASE_ROOT を tmp へ向け、警告の集合を空にし、プロセスのグループを外す。 + + この節のテストは ``environ`` を渡さずに :func:`runtime.inject` を呼ぶため、機密が + 本物の ``os.environ`` へ載る。載せたままにすると後続のテストとその子プロセスへ + 漏れるので、ここで注入前の環境へ戻す。``_isolate_injection_state`` が差し替えた + 注入履歴が ``monkeypatch`` の後始末で戻るより**前**に通す必要がある (戻った後では + 履歴を引けず、載せた値が残る)。autouse の ``_isolate_injection_state`` が先に組み + 立てられる分、この fixture の後始末はそれより先に走る。 + """ + monkeypatch.setenv('DEVBASE_ROOT', str(root)) + monkeypatch.delenv(ACCOUNT_GROUP, raising=False) + monkeypatch.setattr(runtime, '_warned_account_group_refs', set()) + yield root + runtime.clear_injected() + + +def _save(store, backend, ref, data): + getattr(store, backend).save(ref, data) + + +@pytest.mark.parametrize('backend', ['age', 'plaintext']) +def test_inject_does_not_put_the_stores_account_group_into_the_environment( + account_group_root, store, backend): + """受け入れ条件 1: 置き場の値はプロセスへ載らず、ボリュームのグループは default""" + _save(store, backend, GLOBAL, {ACCOUNT_GROUP: 'kkg', 'TOKEN': 't'}) + + runtime.inject(account_group_root, 'web', store=store) + + assert ACCOUNT_GROUP not in os.environ + assert os.environ['TOKEN'] == 't' + assert resolve_account_group() == 'default' + + +@pytest.mark.parametrize('backend', ['age', 'plaintext']) +def test_inject_keeps_the_environments_account_group(account_group_root, store, backend, + monkeypatch): + """受け入れ条件 2: env ファイル由来の値 (プロセスの環境変数) はそのまま""" + monkeypatch.setenv(ACCOUNT_GROUP, 'with') + _save(store, backend, GLOBAL, {ACCOUNT_GROUP: 'kkg', 'TOKEN': 't'}) + + runtime.inject(account_group_root, 'web', store=store) + + assert os.environ[ACCOUNT_GROUP] == 'with' + assert resolve_account_group() == 'with' + + +def test_inject_keeps_the_projects_env_declaration(account_group_root, store, monkeypatch): + """受け入れ条件 2: projects/web/env の宣言 (重ね順 3 が働く場合) でも同じ""" + (account_group_root / 'projects' / 'web' / 'env').write_text(f'{ACCOUNT_GROUP}=with\n') + monkeypatch.setenv(ACCOUNT_GROUP, 'with') + store.age.save(GLOBAL, {ACCOUNT_GROUP: 'kkg'}) + + resolved = runtime.inject(account_group_root, 'web', store=store) + + assert os.environ[ACCOUNT_GROUP] == 'with' + assert ACCOUNT_GROUP not in resolved.values + + +@pytest.mark.parametrize('layer', ['team_global', 'user_global', 'team_web', 'user_web']) +def test_every_store_layer_is_dropped_from_the_environment(account_group_root, layer): + """受け入れ条件 3: 4 つの置き場のどれにあっても載らない""" + store = _FourLayerStore(_layers(**{layer: {ACCOUNT_GROUP: 'kkg', 'K': 'v'}})) + + runtime.inject(account_group_root, 'web', store=store) + + assert ACCOUNT_GROUP not in os.environ + assert os.environ['K'] == 'v' + assert resolve_account_group() == 'default' + + +@pytest.mark.parametrize('layer', ['team_global', 'user_global', 'team_web', 'user_web']) +def test_every_store_layer_loses_to_the_environment(account_group_root, layer, monkeypatch): + """受け入れ条件 3: プロジェクトの置き場 (重ね順 4・5) にあっても env ファイルの値が残る""" + (account_group_root / 'projects' / 'web' / 'env').write_text(f'{ACCOUNT_GROUP}=with\n') + monkeypatch.setenv(ACCOUNT_GROUP, 'with') + store = _FourLayerStore(_layers(**{layer: {ACCOUNT_GROUP: 'kkg'}})) + + runtime.inject(account_group_root, 'web', store=store) + + assert os.environ[ACCOUNT_GROUP] == 'with' + assert resolve_account_group() == 'with' + + +def test_resolve_never_lists_the_account_group(account_group_root): + """受け入れ条件 5: names / global_names / project_names / values のどれにも無い""" + store = _FourLayerStore(_layers( + team_global={ACCOUNT_GROUP: 'kkg', 'A': '1'}, user_global={ACCOUNT_GROUP: 'kkg'}, + team_web={ACCOUNT_GROUP: 'kkg', 'C': '3'}, user_web={ACCOUNT_GROUP: 'kkg'})) + + resolved = runtime.resolve(account_group_root, 'web', store=store) + + assert resolved.values == {'A': '1', 'C': '3'} + assert resolved.global_names == ['A'] + assert resolved.project_names == ['C'] + assert resolved.names == ['A', 'C'] + + +def test_child_env_does_not_carry_the_stores_account_group(account_group_root): + store = _FourLayerStore(_layers(team_global={ACCOUNT_GROUP: 'kkg', 'A': '1'})) + + env = runtime.child_env(account_group_root, 'web', base={'PATH': '/bin'}, store=store) + + assert env == {'PATH': '/bin', 'A': '1'} + + +# --------------------------------------------------------------------------- +# 置き場にあったときの警告 (PLAN62 受け入れ条件 4) +# --------------------------------------------------------------------------- + +def _warnings(caplog): + return [r.getMessage() for r in caplog.records + if r.levelno == logging.WARNING and ACCOUNT_GROUP in r.getMessage()] + + +DELETE = f'devbase env delete {ACCOUNT_GROUP}' + + +@pytest.mark.parametrize('layer, label, how', [ + ('team_global', 'グローバル', DELETE), + ('user_global', '個人のグローバル', f'{DELETE} --user'), + ('team_web', "プロジェクト 'web'", f'{DELETE} -p(projects/web で実行)'), + ('user_web', "個人のプロジェクト 'web'", f'{DELETE} -p --user(projects/web で実行)'), +]) +def test_warns_once_with_the_store_and_how_to_delete(account_group_root, caplog, + layer, label, how): + """受け入れ条件 4: キー名・置き場の種類・消し方を含み、値は含まない""" + store = _FourLayerStore(_layers(**{layer: {ACCOUNT_GROUP: 'kkg'}})) + + with caplog.at_level(logging.WARNING): + runtime.resolve(account_group_root, 'web', store=store) + + messages = _warnings(caplog) + assert len(messages) == 1 + message = messages[0] + assert f'機密の置き場({label})' in message + assert 'projects//env' in message and '$DEVBASE_ROOT/env' in message + assert message.endswith(how) + assert 'kkg' not in message + + +def test_warning_for_a_grouped_reference_names_the_group(account_group_root, caplog): + """グループを持つ参照は label にグループが付き、--group を添える""" + store = _GroupedStore(_layers(user_web={ACCOUNT_GROUP: 'kkg'}), 'with') + + with caplog.at_level(logging.WARNING): + runtime.resolve(account_group_root, 'web', store=store) + + messages = _warnings(caplog) + assert len(messages) == 1 + assert "機密の置き場(個人のプロジェクト 'web'(グループ with))" in messages[0] + assert messages[0].endswith(f'{DELETE} -p --user --group with(projects/web で実行)') + + +def test_warning_for_a_grouped_global_reference_still_adds_the_group(account_group_root, + caplog): + """決定 5: 共通の参照でも --group を付け、どこで打っても同じ置き場を指す""" + store = _GroupedStore(_layers(team_global={ACCOUNT_GROUP: 'kkg'}), 'with') + + with caplog.at_level(logging.WARNING): + runtime.resolve(account_group_root, None, store=store) + + messages = _warnings(caplog) + assert len(messages) == 1 + assert '機密の置き場(グローバル(グループ with))' in messages[0] + assert messages[0].endswith(f'{DELETE} --group with') + + +def test_warning_is_not_repeated_across_stores_and_release(account_group_root, caplog): + """受け入れ条件 4: 同じ参照は release_store と別の store をまたいでも 1 回""" + layers = _layers(team_global={ACCOUNT_GROUP: 'kkg'}) + + with caplog.at_level(logging.WARNING): + runtime.resolve(account_group_root, 'web', store=_FourLayerStore(layers)) + runtime.resolve(account_group_root, 'web', store=_FourLayerStore(layers)) + runtime.release_store() + runtime.resolve(account_group_root, None, store=_FourLayerStore(layers)) + + assert len(_warnings(caplog)) == 1 + + +def test_each_reference_warns_separately(account_group_root, caplog): + store = _FourLayerStore(_layers(team_global={ACCOUNT_GROUP: 'kkg'}, + user_web={ACCOUNT_GROUP: 'kkg'})) + + with caplog.at_level(logging.WARNING): + runtime.resolve(account_group_root, 'web', store=store) + runtime.resolve(account_group_root, 'web', store=store) + + messages = _warnings(caplog) + assert len(messages) == 2 + assert '(グローバル)' in messages[0] + assert "(個人のプロジェクト 'web')" in messages[1] + + +def test_empty_value_still_warns_and_no_key_does_not(account_group_root, caplog): + with caplog.at_level(logging.WARNING): + runtime.resolve(account_group_root, 'web', + store=_FourLayerStore(_layers(team_global={'K': 'v'}))) + assert _warnings(caplog) == [] + + with caplog.at_level(logging.WARNING): + runtime.resolve(account_group_root, 'web', + store=_FourLayerStore(_layers(team_global={ACCOUNT_GROUP: ''}))) + assert len(_warnings(caplog)) == 1