From d038baf46b669b078ce6d6b0a67a4a975f44f506 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Wed, 2 Sep 2026 07:18:35 +0900 Subject: [PATCH 1/3] =?UTF-8?q?fix(gcp):=20=E4=BD=BF=E3=82=8F=E3=82=8C?= =?UTF-8?q?=E3=81=AA=E3=81=84=20GCP=20=E9=8D=B5=E3=82=92=20dev=20=E3=81=AE?= =?UTF-8?q?=E5=88=97=E6=8C=99=E3=81=8B=E3=82=89=E5=A4=96=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GCP_AUTH_MODE=adc が止めるのは「鍵をファイルへ書き出すこと」だけで、環境変数 としての配布は止まらない。GCP_CREDENTIALS_BASE64__* と後方互換キーは生成 compose の environment に名前だけの列挙として残るため、Compose が値を解決して コンテナへ渡す。アカウントグループを分けても、他社のコンテナから env で サービスアカウント鍵の中身が読める状態が続いていた。 entrypoint の devbase_setup_gcp_credentials が読むのはアクティブプロファイルの 鍵 1 本だけなので、それ以外は dev の列挙から外して値ごと渡さない。後方互換キー GOOGLE_APPLICATION_CREDENTIALS_BASE64 は、鍵モードでアクティブプロファイルの鍵 が無いとき**だけ**供給源になるため、その場合に限って残す。ここを一律に外すと プロファイル別キーへ未移行のプロジェクトが鍵を受け取れず壊れる。 除外の判定は gcp_auth へ集約し、os.environ と「生成 compose へ列挙する名前」の 両方を候補にする。runtime.inject の呼び出し順に依存させないため。 除外は dev だけに効かせる方針を踏襲する。共通機密から鍵を受け取っていた非 dev サービス (独自に鍵を持つ batch 等) の列挙は絞らない。 Closes #134 Refs #133 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NMEpP29QkUDLwrsyYjpnDL --- lib/devbase/env/gcp_auth.py | 71 +++++++++++++- lib/devbase/volume/compose.py | 15 ++- tests/env/test_gcp_auth.py | 97 +++++++++++++++++++ tests/volume/test_compose_gcp_auth.py | 129 ++++++++++++++++++++++++++ 4 files changed, 308 insertions(+), 4 deletions(-) diff --git a/lib/devbase/env/gcp_auth.py b/lib/devbase/env/gcp_auth.py index af236298..c19a5b94 100644 --- a/lib/devbase/env/gcp_auth.py +++ b/lib/devbase/env/gcp_auth.py @@ -12,9 +12,15 @@ コンテナへ渡る環境変数を決めるのは**ホスト側の生成 compose** であり、entrypoint の ``export`` / ``unset`` は PID 1 の子プロセスにしか効かない (``docker exec`` の シェルはコンテナの env 設定を継承する)。したがって 2 変数の除外はここで行う。 + +同じ理由で、**鍵の実体を運ぶ base64 変数**も列挙から外す必要がある (issue #134)。 +``adc`` が止めるのは「鍵をファイルへ書き出すこと」だけで、環境変数としての配布は +止まらない。名前が列挙に残る限り Compose が値を解決して渡すため、アカウント +グループを分けても他社の鍵がコンテナ内から ``env`` で読めてしまう。外す対象は +:func:`dev_excluded_env_names` にまとめてある。 """ -from typing import Mapping, Sequence +from typing import Iterable, Mapping, Sequence from devbase.env import keys from devbase.log import get_logger @@ -122,3 +128,66 @@ def key_only_env_names(mode: str) -> Sequence[str]: いた非 dev サービス (独自に鍵を持つ batch 等) の設定ではない。 """ return () if mode == AUTH_MODE_KEY else KEY_ONLY_ENV_KEYS + + +def inactive_profile_key_names(env: Mapping[str, str], + names: Iterable[str] = ()) -> Sequence[str]: + """**アクティブプロファイル以外**の ``GCP_CREDENTIALS_BASE64__*`` を返す。 + + entrypoint の ``devbase_setup_gcp_credentials`` が読むのは + ``GCP_CREDENTIALS_BASE64__${GCP_ACTIVE_PROFILE}`` の **1 本だけ**である。 + 他プロファイルの鍵はどのモードでもコンテナ内で使われないので、dev の列挙 + から外して**値ごと渡さない**。 + + ``adc`` が止めるのは「鍵をファイルへ書き出すこと」だけで、環境変数としての + 配布は止まらない。名前が列挙に残る限り Compose が値を解決して渡すため、 + アカウントグループを分けても他社の鍵がコンテナ内から ``env`` で読める。 + + ``env`` だけでなく ``names`` (生成 compose へ列挙する機密の名前) も走査する。 + ``runtime.inject`` を経ていれば両者は一致するが、その前提に寄りかかると + 「列挙されているのに ``os.environ`` には無い」名前を外し損ねる。判定を呼び + 出し順に依存させないため、両方を候補にする。 + """ + active = keys.gcp_credentials_key(active_profile(env)) + candidates = dict.fromkeys([*env, *names]) + return tuple( + name for name in candidates + if name.startswith(keys.GCP_CREDENTIALS_BASE64_PREFIX) and name != active + ) + + +def _legacy_key_is_the_source(env: Mapping[str, str], mode: str) -> bool: + """後方互換キーが**実際に鍵の供給源になる**か。 + + entrypoint は ``GCP_CREDENTIALS_BASE64__`` が無いときだけ + ``GOOGLE_APPLICATION_CREDENTIALS_BASE64`` へフォールバックする + (``${!var:-${GOOGLE_APPLICATION_CREDENTIALS_BASE64:-}}``)。したがって残す + 必要があるのは「鍵モード」かつ「アクティブプロファイルの鍵が無い」ときだけ。 + + ここを落とすと、プロファイル別キーへ未移行のプロジェクトが鍵を受け取れ + なくなって壊れる。空文字を「無い」として扱うのは + :func:`has_service_account_key` と同じ判定である。 + """ + if mode != AUTH_MODE_KEY: + return False + return not env.get(keys.gcp_credentials_key(active_profile(env))) + + +def dev_excluded_env_names(env: Mapping[str, str], mode: str, + names: Iterable[str] = ()) -> Sequence[str]: + """dev の列挙から外す変数名を返す (鍵のパス + 使われない鍵の実体)。 + + :func:`key_only_env_names` が外すのは「鍵ファイルのパスを指す 2 変数」で、 + ``DefaultCredentialsError`` を避けるためのもの。こちらはそれに加えて、 + **鍵の中身を運ぶ base64 変数のうちコンテナ内で使われないもの**を外す。 + + 許可リストとして働くのが要点である。プロジェクト側の ``env`` で不要な鍵を + 1 本ずつ空文字に潰す拒否リスト方式だと、グローバルへプロファイルが増える + たびに全プロジェクトへ追記が要り、漏れてもエラーにならない。 + """ + excluded = list(key_only_env_names(mode)) + excluded.extend(inactive_profile_key_names(env, names)) + if not _legacy_key_is_the_source(env, mode): + excluded.append(keys.GOOGLE_APPLICATION_CREDENTIALS_BASE64) + # 呼び出し側が渡した順序を保ちつつ重複を除く + return tuple(dict.fromkeys(excluded)) diff --git a/lib/devbase/volume/compose.py b/lib/devbase/volume/compose.py index b129a1a1..26e592f2 100644 --- a/lib/devbase/volume/compose.py +++ b/lib/devbase/volume/compose.py @@ -626,17 +626,26 @@ def generate_scaled_compose( **gcp_auth.container_env(os.environ), } - # ADC モードでは鍵モード専用の 2 変数を **dev の列挙から外す**。名前が載ら + # コンテナ内で使われない GCP の変数を **dev の列挙から外す**。名前が載ら # なければ Compose はその変数をコンテナへ渡さないので、docker exec のシェル # から見ても未設定になる。値を空にするだけでは entrypoint の外に効かない。 # - # 除外は dev だけに効かせる。元々この 2 変数を env_file から受け取っていた + # 外すのは 2 種類ある。ADC モードでの鍵ファイルのパス 2 変数 + # (DefaultCredentialsError を避けるため) と、アクティブプロファイル以外の + # GCP_CREDENTIALS_BASE64__* および使われない後方互換キー (鍵の実体が他社の + # コンテナへ渡るのを防ぐため / issue #134)。判定は gcp_auth に集約する。 + # + # 除外は dev だけに効かせる。元々これらを env_file から受け取っていた # 非 dev サービス (独自に鍵を持つ batch 等) から値を奪うと、直書きを消すのと # 同じようにそのサービスを壊す。 auth_mode = dev_environment[keys.GCP_AUTH_MODE] + enumerated = [*secret_env_names, + *(global_env_names or ()), + *(project_env_names or ())] secret_names = _SecretNames( secret_env_names, global_env_names, project_env_names, - dev_excluded=gcp_auth.key_only_env_names(auth_mode)) + dev_excluded=gcp_auth.dev_excluded_env_names( + os.environ, auth_mode, enumerated)) # VS Code Server は再作成をまたいで保つためコンテナ 1 つに 1 本の named # volume を宛てる (PLAN36)。プロジェクトが自分で ~/.vscode-server を diff --git a/tests/env/test_gcp_auth.py b/tests/env/test_gcp_auth.py index 2e8753ab..ecff91ba 100644 --- a/tests/env/test_gcp_auth.py +++ b/tests/env/test_gcp_auth.py @@ -145,3 +145,100 @@ def test_container_env_carries_the_resolved_mode(): assert gcp_auth.container_env({})[keys.GCP_AUTH_MODE] == "adc" assert gcp_auth.container_env( {"GCP_CREDENTIALS_BASE64__default": "eyJ9"})[keys.GCP_AUTH_MODE] == "key" + + +# --------------------------------------------------------------------------- +# 鍵の実体 (base64) の除外 (issue #134) +# --------------------------------------------------------------------------- + +def test_inactive_profile_keys_are_excluded(): + """アクティブプロファイル以外の鍵は entrypoint が読まないので外す。""" + env = {keys.GCP_ACTIVE_PROFILE: "with", + "GCP_CREDENTIALS_BASE64__default": "eyJ9", + "GCP_CREDENTIALS_BASE64__kkg": "eyJ9", + "GCP_CREDENTIALS_BASE64__with": "eyJ9"} + + excluded = set(gcp_auth.inactive_profile_key_names(env)) + + assert excluded == {"GCP_CREDENTIALS_BASE64__default", + "GCP_CREDENTIALS_BASE64__kkg"} + + +def test_active_profile_key_is_kept(): + """自分のプロファイルの鍵は entrypoint が使うので残す。""" + env = {keys.GCP_ACTIVE_PROFILE: "with", + "GCP_CREDENTIALS_BASE64__with": "eyJ9"} + + assert gcp_auth.inactive_profile_key_names(env) == () + + +def test_unset_profile_defaults_to_the_default_key(): + """GCP_ACTIVE_PROFILE 未設定なら default の鍵が残る。""" + env = {"GCP_CREDENTIALS_BASE64__default": "eyJ9", + "GCP_CREDENTIALS_BASE64__prod": "eyJ9"} + + assert gcp_auth.inactive_profile_key_names(env) == ( + "GCP_CREDENTIALS_BASE64__prod",) + + +def test_adc_excludes_the_legacy_key(): + """adc では entrypoint が鍵を一切読まないので後方互換キーも外す。""" + env = {keys.GCP_AUTH_MODE: "adc", + "GOOGLE_APPLICATION_CREDENTIALS_BASE64": "eyJ9"} + + assert keys.GOOGLE_APPLICATION_CREDENTIALS_BASE64 in \ + gcp_auth.dev_excluded_env_names(env, gcp_auth.AUTH_MODE_ADC) + + +def test_key_mode_with_profile_key_excludes_the_legacy_key(): + """プロファイル別キーが使われるならフォールバックは発生しない。""" + env = {keys.GCP_ACTIVE_PROFILE: "with", + "GCP_CREDENTIALS_BASE64__with": "eyJ9", + "GOOGLE_APPLICATION_CREDENTIALS_BASE64": "eyJ9"} + + assert keys.GOOGLE_APPLICATION_CREDENTIALS_BASE64 in \ + gcp_auth.dev_excluded_env_names(env, gcp_auth.AUTH_MODE_KEY) + + +def test_key_mode_without_profile_key_keeps_the_legacy_key(): + """後方互換キーが鍵の供給源のときだけ残す。 + + ここを外すと、プロファイル別キーへ未移行のプロジェクトが鍵を受け取れなく + なって起動時に壊れる。 + """ + env = {"GOOGLE_APPLICATION_CREDENTIALS_BASE64": "eyJ9"} + + assert keys.GOOGLE_APPLICATION_CREDENTIALS_BASE64 not in \ + gcp_auth.dev_excluded_env_names(env, gcp_auth.AUTH_MODE_KEY) + + +def test_empty_profile_key_counts_as_absent(): + """空文字は has_service_account_key と同じく「無い」扱い。""" + env = {keys.GCP_ACTIVE_PROFILE: "with", + "GCP_CREDENTIALS_BASE64__with": "", + "GOOGLE_APPLICATION_CREDENTIALS_BASE64": "eyJ9"} + + assert keys.GOOGLE_APPLICATION_CREDENTIALS_BASE64 not in \ + gcp_auth.dev_excluded_env_names(env, gcp_auth.AUTH_MODE_KEY) + + +def test_dev_excluded_keeps_the_key_only_names(): + """既存の 2 変数の除外は据え置き (adc のときだけ外す)。""" + adc = gcp_auth.dev_excluded_env_names({}, gcp_auth.AUTH_MODE_ADC) + key = gcp_auth.dev_excluded_env_names( + {"GCP_CREDENTIALS_BASE64__default": "eyJ9"}, gcp_auth.AUTH_MODE_KEY) + + assert "GOOGLE_APPLICATION_CREDENTIALS" in adc + assert "BIGQUERY_KEY_FILE" in adc + assert "GOOGLE_APPLICATION_CREDENTIALS" not in key + assert "BIGQUERY_KEY_FILE" not in key + + +def test_dev_excluded_has_no_duplicates(): + env = {keys.GCP_AUTH_MODE: "adc", + "GOOGLE_APPLICATION_CREDENTIALS_BASE64": "eyJ9", + "GCP_CREDENTIALS_BASE64__prod": "eyJ9"} + + names = gcp_auth.dev_excluded_env_names(env, gcp_auth.AUTH_MODE_ADC) + + assert len(names) == len(set(names)) diff --git a/tests/volume/test_compose_gcp_auth.py b/tests/volume/test_compose_gcp_auth.py index 1568b6cd..6be349ae 100644 --- a/tests/volume/test_compose_gcp_auth.py +++ b/tests/volume/test_compose_gcp_auth.py @@ -336,3 +336,132 @@ def test_other_profiles_key_does_not_enable_key_mode(project, monkeypatch): env = env_map(project) assert env["GCP_AUTH_MODE"] == "adc" assert "GOOGLE_APPLICATION_CREDENTIALS" not in env + + +# --------------------------------------------------------------------------- +# 鍵の実体 (base64) が他グループへ渡らないこと (issue #134) +# --------------------------------------------------------------------------- + +# グローバルに複数プロファイルの鍵がある状態で列挙される名前 +MULTI_PROFILE_SECRETS = [ + "ANTHROPIC_API_KEY", + "GCP_CREDENTIALS_BASE64__default", + "GCP_CREDENTIALS_BASE64__with", + "GOOGLE_APPLICATION_CREDENTIALS_BASE64", +] + + +@pytest.fixture +def multi_profile(project, monkeypatch): + """with グループのプロジェクトから、nyle の鍵も見えている状態を作る。""" + monkeypatch.setenv("GCP_ACTIVE_PROFILE", "with") + monkeypatch.setenv("GCP_CREDENTIALS_BASE64__default", "eyJ9") + monkeypatch.setenv("GCP_CREDENTIALS_BASE64__with", "eyJ9") + monkeypatch.setenv("GOOGLE_APPLICATION_CREDENTIALS_BASE64", "eyJ9") + return project + + +def test_other_profiles_key_is_not_passed_to_the_container(multi_profile): + """他プロファイルの鍵と、使われない後方互換キーを渡さない。 + + #133 で分離したのは認証情報の置き場所までで、GCP_ACTIVE_PROFILE を変えても + 鍵の配布範囲は狭まらなかった。 + """ + generate_scaled_compose(1, secret_env_names=MULTI_PROFILE_SECRETS) + + names = env_names(multi_profile) + assert "GCP_CREDENTIALS_BASE64__default" not in names + assert "GOOGLE_APPLICATION_CREDENTIALS_BASE64" not in names + # 自分のプロファイルの鍵と無関係な機密は従来どおり渡す + assert "GCP_CREDENTIALS_BASE64__with" in names + assert "ANTHROPIC_API_KEY" in names + + +def test_adc_project_gets_no_key_material_at_all(project, monkeypatch): + """#133 の with-ai-dev の構成。鍵の実体が 1 本も渡らない。""" + monkeypatch.setenv("GCP_AUTH_MODE", "adc") + monkeypatch.setenv("GCP_ACTIVE_PROFILE", "with") + monkeypatch.setenv("GCP_CREDENTIALS_BASE64__default", "eyJ9") + monkeypatch.setenv("GOOGLE_APPLICATION_CREDENTIALS_BASE64", "eyJ9") + + generate_scaled_compose( + 1, secret_env_names=["GCP_CREDENTIALS_BASE64__default", + "GOOGLE_APPLICATION_CREDENTIALS_BASE64"]) + + names = env_names(project) + assert not [name for name in names if "CREDENTIALS_BASE64" in name] + + +def test_legacy_key_still_drives_auto_key_mode(project, monkeypatch): + """adc を宣言しないと、後方互換キーが鍵モードを引き起こし残り続ける。 + + #133 で with-ai-dev が踏んだ経路である。アクティブプロファイルの鍵が無いと + ``has_service_account_key`` は後方互換キーへフォールバックして ``key`` を + 返し、entrypoint も同じ判定でその鍵を書き出す。したがってこの構成では + 後方互換キーを外せない (外すと鍵の供給源を失って壊れる)。 + + この除外だけでは足りず、プロジェクト側の ``GCP_AUTH_MODE=adc`` の宣言と + 組み合わせて初めて他社の鍵が渡らなくなる。 + """ + monkeypatch.setenv("GCP_ACTIVE_PROFILE", "with") + monkeypatch.setenv("GCP_CREDENTIALS_BASE64__default", "eyJ9") + monkeypatch.setenv("GOOGLE_APPLICATION_CREDENTIALS_BASE64", "eyJ9") + + generate_scaled_compose( + 1, secret_env_names=["GCP_CREDENTIALS_BASE64__default", + "GOOGLE_APPLICATION_CREDENTIALS_BASE64"]) + + names = env_names(project) + assert env_map(project)["GCP_AUTH_MODE"] == "key" + # 他プロファイルの鍵は外れる + assert "GCP_CREDENTIALS_BASE64__default" not in names + # 供給源になっている後方互換キーは残る + assert "GOOGLE_APPLICATION_CREDENTIALS_BASE64" in names + + +def test_enumerated_but_unset_names_are_excluded_too(project, monkeypatch): + """判定を runtime.inject の呼び出し順に依存させない。 + + os.environ に載る前でも、列挙される名前から他プロファイルの鍵を外す。 + """ + monkeypatch.setenv("GCP_ACTIVE_PROFILE", "with") + monkeypatch.setenv("GCP_CREDENTIALS_BASE64__with", "eyJ9") + + generate_scaled_compose( + 1, secret_env_names=["GCP_CREDENTIALS_BASE64__kkg", + "GCP_CREDENTIALS_BASE64__with"]) + + names = env_names(project) + assert "GCP_CREDENTIALS_BASE64__kkg" not in names + assert "GCP_CREDENTIALS_BASE64__with" in names + + +def test_legacy_key_survives_when_it_is_the_only_source(project, monkeypatch): + """プロファイル別キーへ未移行のプロジェクトを壊さない。 + + 後方互換キーだけで鍵モードになる構成では、これを外すと entrypoint が鍵を + 書き出せなくなる。 + """ + monkeypatch.setenv("GOOGLE_APPLICATION_CREDENTIALS_BASE64", "eyJ9") + + generate_scaled_compose( + 1, secret_env_names=["GOOGLE_APPLICATION_CREDENTIALS_BASE64"]) + + assert env_map(project)["GCP_AUTH_MODE"] == "key" + assert "GOOGLE_APPLICATION_CREDENTIALS_BASE64" in env_names(project) + + +def test_non_dev_services_keep_the_key_material(project, monkeypatch): + """除外は dev だけ。共通機密から鍵を受け取っていた非 dev サービスは維持。""" + (project / "compose.yml").write_text(ENV_FILE_COMPOSE) + monkeypatch.setenv("GCP_ACTIVE_PROFILE", "with") + monkeypatch.setenv("GCP_CREDENTIALS_BASE64__default", "eyJ9") + + generate_scaled_compose( + 1, + secret_env_names=["GCP_CREDENTIALS_BASE64__default"], + global_env_names=["GCP_CREDENTIALS_BASE64__default"], + project_env_names=[]) + + assert "GCP_CREDENTIALS_BASE64__default" in services(project)["batch"]["environment"] + assert "GCP_CREDENTIALS_BASE64__default" not in env_names(project) From d9d2fbb9f27cbfc0e1ff02697e1f0ec098dca486 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Wed, 2 Sep 2026 08:30:22 +0900 Subject: [PATCH 2/3] =?UTF-8?q?docs(gcp):=20=E9=8D=B5=E3=81=AE=E5=AE=9F?= =?UTF-8?q?=E4=BD=93=E3=82=92=20dev=20=E3=81=B8=E6=B8=A1=E3=81=99=E7=AF=84?= =?UTF-8?q?=E5=9B=B2=E3=82=92=E6=9B=B8=E3=81=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 除去対象として鍵ファイルのパス 2 変数しか書かれておらず、実装が GCP_CREDENTIALS_BASE64__* と後方互換キーも外すようになった点が抜けていた。 「adc が止めるのは鍵をファイルへ書き出すことだけで、base64 変数の配布は 止まらない」という前提と、3 変数それぞれの可否を表で示す。後方互換キーだけが 条件付きで残る理由 (アクティブプロファイルの鍵が無いときの供給源) も添える。 アクティブプロファイルの鍵が無く GCP_AUTH_MODE も未宣言だと後方互換キーが key モードを引き起こして渡り続ける点は、踏みやすいので Note / Warning にした。 Refs #134 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NMEpP29QkUDLwrsyYjpnDL --- docs/user/environment-variables.md | 26 ++++++++++++++++++++++++++ docs/user/google-auth.md | 18 ++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/docs/user/environment-variables.md b/docs/user/environment-variables.md index 220a5b34..743147ab 100644 --- a/docs/user/environment-variables.md +++ b/docs/user/environment-variables.md @@ -118,6 +118,32 @@ ADC を既定の経路にできます。鍵が要る場面のために切り替 認証方式の宣言なので、独自に鍵をマウントしている `batch` のような非 dev サービスが `environment:` や `env_file` で受け取っている 2 変数はそのまま残します。 +##### 鍵の実体を渡す範囲 + +`adc` が止めるのは **鍵をファイルへ書き出すこと**だけで、鍵を運ぶ base64 変数の配布までは +止まりません。名前が生成 compose の `environment:` に載っている限り、Compose が値を解決して +コンテナへ渡すため、`env` で中身が読めてしまいます。 + +そこで devbase は、コンテナ内で**使われない**鍵の変数も dev の列挙から外します。entrypoint が +読むのはアクティブプロファイルの鍵 1 本だけだからです。 + +| 変数 | dev へ渡すか | +|---|---| +| `GCP_CREDENTIALS_BASE64__<アクティブプロファイル>` | 渡す | +| `GCP_CREDENTIALS_BASE64__<それ以外>` | **渡さない**(モードによらず) | +| `GOOGLE_APPLICATION_CREDENTIALS_BASE64` | `key` かつアクティブプロファイルの鍵が無いときだけ渡す | + +3 行目は後方互換キーの扱いです。entrypoint はアクティブプロファイルの鍵が無いときだけ +このキーへフォールバックするので、**そのときは供給源になるため外せません**。逆に言うと、 +プロファイル別キーへ移行済みのプロジェクトでは渡らなくなります。 + +これはアカウントグループ(`DEVBASE_ACCOUNT_GROUP`)をまたいだ鍵の共有を防ぐためのものです。 +グループを分けてもこの除外が無いと、別グループ用のサービスアカウント鍵がコンテナへ届きます。 + +> **Note:** アクティブプロファイルの鍵が無く、`GCP_AUTH_MODE` も宣言していない構成では、 +> 後方互換キーが `key` モードを引き起こして渡り続けます。別グループの鍵を持ち込みたくない +> なら、プロジェクトの `env` に `GCP_AUTH_MODE=adc` を明示してください。 + ```bash # ADC を使う(推奨) echo 'GCP_AUTH_MODE=adc' >> projects//env diff --git a/docs/user/google-auth.md b/docs/user/google-auth.md index b885b842..0e05efa2 100644 --- a/docs/user/google-auth.md +++ b/docs/user/google-auth.md @@ -535,6 +535,24 @@ gws auth login --scopes openid,https://www.googleapis.com/auth/userinfo.email,ht 独自に鍵をマウントしている `batch` のような非 dev サービスへ書いた設定や、そのサービスが `env_file` で受け取っていた 2 変数には触りません。 +**鍵の実体(base64)も、使われないものは渡しません。** `adc` が止めるのは鍵をファイルへ +書き出すことだけで、`GCP_CREDENTIALS_BASE64__*` の配布までは止まらないためです。名前が +生成 compose に載っていれば値はコンテナへ届き、`env` で中身が読めます。 + +| 変数 | dev へ渡すか | +|---|---| +| `GCP_CREDENTIALS_BASE64__<アクティブプロファイル>` | 渡す | +| `GCP_CREDENTIALS_BASE64__<それ以外>` | **渡さない**(モードによらず) | +| `GOOGLE_APPLICATION_CREDENTIALS_BASE64` | `key` かつアクティブプロファイルの鍵が無いときだけ渡す | + +entrypoint が読むのはアクティブプロファイルの鍵 1 本だけなので、それ以外は渡す必要が +ありません。後方互換キーだけは、アクティブプロファイルの鍵が無いときに供給源になるため +その場合に限って残します。 + +> **Warning:** アクティブプロファイルの鍵が無く `GCP_AUTH_MODE` も宣言していないと、 +> 後方互換キーが `key` モードを引き起こして渡り続けます。別のアカウントグループの鍵を +> 持ち込みたくないプロジェクトでは、`env` に `GCP_AUTH_MODE=adc` を明示してください。 + **鍵が要るのはどういう場面か。** ユーザー認証では権限が足りない、あるいは人に紐づかない 実行主体が必要な場面です。たとえば本番データセットへの読み取りがサービスアカウントにしか 付与されていない場合や、コンテナ内から実行するバッチが特定の SA として動く必要がある場合です。 From 52aafcd030cbf3c8432560d85617a83cf0d392d9 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Wed, 2 Sep 2026 08:44:52 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix(gcp):=20adc=20=E3=81=A7=E3=81=AF?= =?UTF-8?q?=E9=8D=B5=E3=82=92=201=20=E6=9C=AC=E3=82=82=E6=B8=A1=E3=81=95?= =?UTF-8?q?=E3=81=9A=E3=80=81=E7=9B=B4=E6=9B=B8=E3=81=8D=E3=82=82=E5=8F=96?= =?UTF-8?q?=E3=82=8A=E9=99=A4=E3=81=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cross-review の指摘 2 件への対応。 1. adc でアクティブプロファイルの鍵が残っていた。entrypoint の devbase_setup_gcp_credentials は adc だと creds_b64 を読む前に return する ので、adc のコンテナは鍵の実体を 1 本も必要としない。アクティブ分だけ残すと 「鍵を使わない」と宣言したコンテナの env から秘密鍵が読めてしまう。 既存テスト test_adc_drops_the_key_only_variables の期待値を 「渡す」から「渡さない」へ変更した (理由は docstring に記載)。 2. 除外が列挙にしか効かず、compose.yml の dev へ直書きされた鍵が生成物に 残っていた。_drop_env_names へ同じ除外集合を渡し、auth_mode のガードを外す。 key モードでは dev_excluded_env_names が鍵パス 2 変数を返さないため、 「鍵モードでは直書きのパスを尊重する」既存の挙動は保たれる。 あわせて、除外集合の候補が機密の列挙とホストの環境変数しか見ておらず、 compose.yml へ直書きされた別プロファイルの鍵を外し損ねる穴も塞いだ (_service_env_names を追加し、3 か所すべてを候補にする)。 ドキュメントの表はモード別の 2 列にし、adc で全て渡さないことと、直書きにも 除外が効くことを書いた。 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NMEpP29QkUDLwrsyYjpnDL --- docs/user/environment-variables.md | 24 ++++--- docs/user/google-auth.md | 26 +++++--- lib/devbase/env/gcp_auth.py | 7 ++ lib/devbase/volume/compose.py | 55 ++++++++++++---- tests/volume/test_compose_gcp_auth.py | 92 ++++++++++++++++++++++++++- 5 files changed, 171 insertions(+), 33 deletions(-) diff --git a/docs/user/environment-variables.md b/docs/user/environment-variables.md index 743147ab..6ff24664 100644 --- a/docs/user/environment-variables.md +++ b/docs/user/environment-variables.md @@ -127,15 +127,23 @@ ADC を既定の経路にできます。鍵が要る場面のために切り替 そこで devbase は、コンテナ内で**使われない**鍵の変数も dev の列挙から外します。entrypoint が 読むのはアクティブプロファイルの鍵 1 本だけだからです。 -| 変数 | dev へ渡すか | -|---|---| -| `GCP_CREDENTIALS_BASE64__<アクティブプロファイル>` | 渡す | -| `GCP_CREDENTIALS_BASE64__<それ以外>` | **渡さない**(モードによらず) | -| `GOOGLE_APPLICATION_CREDENTIALS_BASE64` | `key` かつアクティブプロファイルの鍵が無いときだけ渡す | +| 変数 | `adc` | `key` | +|---|---|---| +| `GCP_CREDENTIALS_BASE64__<アクティブプロファイル>` | **渡さない** | 渡す | +| `GCP_CREDENTIALS_BASE64__<それ以外>` | 渡さない | 渡さない | +| `GOOGLE_APPLICATION_CREDENTIALS_BASE64` | 渡さない | アクティブプロファイルの鍵が無いときだけ渡す | + +`adc` で**アクティブプロファイルの鍵も渡さない**のが要点です。entrypoint の +`devbase_setup_gcp_credentials` は `adc` だと鍵を読む前に return するため、鍵の実体は +1 本も要りません。アクティブ分だけ残すと「鍵を使わない」と宣言したコンテナの `env` から +秘密鍵が読めてしまいます。 + +除外は `compose.yml` の dev サービスへ**直書き**された変数にも効きます。列挙を絞るだけでは +直書きが生成物に残り、対策を迂回するためです。非 dev サービスの明示設定には触りません。 -3 行目は後方互換キーの扱いです。entrypoint はアクティブプロファイルの鍵が無いときだけ -このキーへフォールバックするので、**そのときは供給源になるため外せません**。逆に言うと、 -プロファイル別キーへ移行済みのプロジェクトでは渡らなくなります。 +3 行目は後方互換キーの扱いです。entrypoint は `key` モードでアクティブプロファイルの鍵が +無いときだけこのキーへフォールバックするので、**そのときは供給源になるため外せません**。 +逆に言うと、プロファイル別キーへ移行済みのプロジェクトでは渡らなくなります。 これはアカウントグループ(`DEVBASE_ACCOUNT_GROUP`)をまたいだ鍵の共有を防ぐためのものです。 グループを分けてもこの除外が無いと、別グループ用のサービスアカウント鍵がコンテナへ届きます。 diff --git a/docs/user/google-auth.md b/docs/user/google-auth.md index 0e05efa2..3dd26ade 100644 --- a/docs/user/google-auth.md +++ b/docs/user/google-auth.md @@ -539,15 +539,23 @@ gws auth login --scopes openid,https://www.googleapis.com/auth/userinfo.email,ht 書き出すことだけで、`GCP_CREDENTIALS_BASE64__*` の配布までは止まらないためです。名前が 生成 compose に載っていれば値はコンテナへ届き、`env` で中身が読めます。 -| 変数 | dev へ渡すか | -|---|---| -| `GCP_CREDENTIALS_BASE64__<アクティブプロファイル>` | 渡す | -| `GCP_CREDENTIALS_BASE64__<それ以外>` | **渡さない**(モードによらず) | -| `GOOGLE_APPLICATION_CREDENTIALS_BASE64` | `key` かつアクティブプロファイルの鍵が無いときだけ渡す | - -entrypoint が読むのはアクティブプロファイルの鍵 1 本だけなので、それ以外は渡す必要が -ありません。後方互換キーだけは、アクティブプロファイルの鍵が無いときに供給源になるため -その場合に限って残します。 +| 変数 | `adc` | `key` | +|---|---|---| +| `GCP_CREDENTIALS_BASE64__<アクティブプロファイル>` | **渡さない** | 渡す | +| `GCP_CREDENTIALS_BASE64__<それ以外>` | 渡さない | 渡さない | +| `GOOGLE_APPLICATION_CREDENTIALS_BASE64` | 渡さない | アクティブプロファイルの鍵が無いときだけ渡す | + +`adc` で**アクティブプロファイルの鍵も渡さない**のが要点です。entrypoint の +`devbase_setup_gcp_credentials` は `adc` だと鍵を読む前に return するため、鍵の実体は +1 本も要りません。アクティブ分だけ残すと「鍵を使わない」と宣言したコンテナの `env` から +秘密鍵が読めてしまいます。 + +除外は `compose.yml` の dev サービスへ**直書き**された変数にも効きます。列挙を絞るだけでは +直書きが生成物に残り、対策を迂回するためです。非 dev サービスの明示設定には触りません。 + +`key` モードで entrypoint が読むのはアクティブプロファイルの鍵 1 本だけなので、それ以外は +渡す必要がありません。後方互換キーだけは、`key` でアクティブプロファイルの鍵が無いときに +供給源になるため、その場合に限って残します。 > **Warning:** アクティブプロファイルの鍵が無く `GCP_AUTH_MODE` も宣言していないと、 > 後方互換キーが `key` モードを引き起こして渡り続けます。別のアカウントグループの鍵を diff --git a/lib/devbase/env/gcp_auth.py b/lib/devbase/env/gcp_auth.py index c19a5b94..8f88711a 100644 --- a/lib/devbase/env/gcp_auth.py +++ b/lib/devbase/env/gcp_auth.py @@ -181,12 +181,19 @@ def dev_excluded_env_names(env: Mapping[str, str], mode: str, ``DefaultCredentialsError`` を避けるためのもの。こちらはそれに加えて、 **鍵の中身を運ぶ base64 変数のうちコンテナ内で使われないもの**を外す。 + ``adc`` では**アクティブプロファイルの鍵も外す**。entrypoint の + ``devbase_setup_gcp_credentials`` は ``adc`` だと ``creds_b64`` を使わずに + return するので、鍵の実体は 1 本も要らない。アクティブ分だけ残すと、 + 「鍵を使わない」と宣言したコンテナの ``env`` から秘密鍵が読めてしまう。 + 許可リストとして働くのが要点である。プロジェクト側の ``env`` で不要な鍵を 1 本ずつ空文字に潰す拒否リスト方式だと、グローバルへプロファイルが増える たびに全プロジェクトへ追記が要り、漏れてもエラーにならない。 """ excluded = list(key_only_env_names(mode)) excluded.extend(inactive_profile_key_names(env, names)) + if mode != AUTH_MODE_KEY: + excluded.append(keys.gcp_credentials_key(active_profile(env))) if not _legacy_key_is_the_source(env, mode): excluded.append(keys.GOOGLE_APPLICATION_CREDENTIALS_BASE64) # 呼び出し側が渡した順序を保ちつつ重複を除く diff --git a/lib/devbase/volume/compose.py b/lib/devbase/volume/compose.py index 26e592f2..f0dcc999 100644 --- a/lib/devbase/volume/compose.py +++ b/lib/devbase/volume/compose.py @@ -249,6 +249,22 @@ def _mask_secret_environment( service['environment'] = list(secrets) +def _service_env_names(service: dict) -> List[str]: + """service の ``environment`` に書かれているキー名を読む。 + + map 記法 (``KEY: VALUE``) と list 記法 (``KEY=VALUE`` / 名前参照のみ) の + 両方を扱う。除外集合を組み立てるとき、``compose.yml`` の直書きも候補に + 含めるために要る。env や機密の列挙だけを見ていると、直書きされた別 + プロファイルの鍵を外し損ねる (issue #134)。 + """ + existing = service.get('environment') + if isinstance(existing, dict): + return [str(name) for name in existing] + if isinstance(existing, list): + return [str(item).split('=', 1)[0].strip() for item in existing] + return [] + + def _drop_env_names(service: dict, names: Iterable[str]) -> None: """service の ``environment`` から指定キーを**丸ごと**取り除く。 @@ -258,9 +274,10 @@ def _drop_env_names(service: dict, names: Iterable[str]) -> None: ``DefaultCredentialsError`` で落ちるため、「値が空」でも「値なし参照」でも 足りず、**渡さない**しかない。 - 機密の列挙 (:func:`gcp_auth.key_only_env_names`) を絞るだけでは、元の - ``compose.yml`` の ``environment`` に直書きされたキーが生成物に残る。 - map / list の両記法を扱い、空になった ``environment`` は消す。 + 機密の列挙 (:func:`gcp_auth.dev_excluded_env_names`) を絞るだけでは、元の + ``compose.yml`` の ``environment`` に直書き・名前参照されたキーが生成物に + 残り、鍵の実体もそこから漏れる (issue #134)。map / list の両記法を扱い、 + 空になった ``environment`` は消す。 """ drop = set(names) if not drop: @@ -639,13 +656,19 @@ def generate_scaled_compose( # 非 dev サービス (独自に鍵を持つ batch 等) から値を奪うと、直書きを消すのと # 同じようにそのサービスを壊す。 auth_mode = dev_environment[keys.GCP_AUTH_MODE] + # + # 候補は 3 か所から集める。機密の列挙・ホストの環境変数・元 compose.yml の + # dev への直書きである。どれか 1 つでも欠けると、そこに書かれた別プロファイル + # の鍵を外し損ねる。 enumerated = [*secret_env_names, *(global_env_names or ()), - *(project_env_names or ())] + *(project_env_names or ()), + *_service_env_names(dev_service)] + dev_excluded = gcp_auth.dev_excluded_env_names( + os.environ, auth_mode, enumerated) secret_names = _SecretNames( secret_env_names, global_env_names, project_env_names, - dev_excluded=gcp_auth.dev_excluded_env_names( - os.environ, auth_mode, enumerated)) + dev_excluded=dev_excluded) # VS Code Server は再作成をまたいで保つためコンテナ 1 つに 1 本の named # volume を宛てる (PLAN36)。プロジェクトが自分で ~/.vscode-server を @@ -668,17 +691,23 @@ def generate_scaled_compose( ) # 列挙を絞るだけでは、元の compose.yml が environment に**直書き**している - # 2 変数が生成物に残る。adc では dev に鍵を書かないので、パスが残っていること - # 自体が DefaultCredentialsError の原因になる。 + # 変数が生成物に残る。adc では dev に鍵を書かないので、鍵ファイルのパスが + # 残っていること自体が DefaultCredentialsError の原因になる。同じ理屈で、 + # 鍵の実体 (GCP_CREDENTIALS_BASE64__*) を直書き・名前参照している compose.yml + # は列挙の絞り込みを迂回して鍵をコンテナへ渡してしまう (issue #134)。 + # したがって除外集合は列挙と生成物の両方へ同じものを適用する。 + # + # 鍵モードのガードは要らない。dev_excluded_env_names は key モードでは + # 鍵パスの 2 変数を返さないので、「鍵モードでは直書きのパスを尊重する」挙動は + # そのまま保たれる。 # # 取り除くのは **dev インスタンスだけ**。`GCP_AUTH_MODE` は dev コンテナの # 認証方式の宣言であり、独自に鍵をマウントしている非 dev サービス (batch 等) の # 明示設定まで消すと、そのサービスを壊してしまう。 - if auth_mode != gcp_auth.AUTH_MODE_KEY: - for index in range(1, scale + 1): - service = scaled_services.get(f'{dev_service_name}-{index}') - if isinstance(service, dict): - _drop_env_names(service, gcp_auth.KEY_ONLY_ENV_KEYS) + for index in range(1, scale + 1): + service = scaled_services.get(f'{dev_service_name}-{index}') + if isinstance(service, dict): + _drop_env_names(service, dev_excluded) scaled_config = { 'services': scaled_services, diff --git a/tests/volume/test_compose_gcp_auth.py b/tests/volume/test_compose_gcp_auth.py index 6be349ae..cf2ae8bc 100644 --- a/tests/volume/test_compose_gcp_auth.py +++ b/tests/volume/test_compose_gcp_auth.py @@ -105,7 +105,14 @@ def test_key_mode_is_auto_detected(project, monkeypatch): def test_adc_drops_the_key_only_variables(project, monkeypatch): - """AC12 (1): 2 変数がコンテナへ渡らない。""" + """AC12 (1): 2 変数がコンテナへ渡らない。 + + ``GCP_CREDENTIALS_BASE64__default`` (アクティブプロファイルの鍵) の期待値は + レビュー指摘で「渡す」から「渡さない」へ変えた。entrypoint の + ``devbase_setup_gcp_credentials`` は ``adc`` だと ``creds_b64`` を読む前に + return するため、adc のコンテナは鍵の実体を 1 本も必要としない。アクティブ分 + だけ残すと、鍵を使わないと宣言したコンテナの ``env`` から秘密鍵が読めてしまう。 + """ monkeypatch.setenv("GCP_AUTH_MODE", "adc") monkeypatch.setenv("GCP_CREDENTIALS_BASE64__default", "eyJ9") @@ -114,8 +121,9 @@ def test_adc_drops_the_key_only_variables(project, monkeypatch): names = env_names(project) assert "GOOGLE_APPLICATION_CREDENTIALS" not in names assert "BIGQUERY_KEY_FILE" not in names - # 鍵そのものと他の機密は従来どおり渡す - assert "GCP_CREDENTIALS_BASE64__default" in names + # adc では entrypoint が鍵を読まないので、アクティブ分の鍵も渡さない + assert "GCP_CREDENTIALS_BASE64__default" not in names + # 鍵と無関係な機密は従来どおり渡す assert "ANTHROPIC_API_KEY" in names @@ -465,3 +473,81 @@ def test_non_dev_services_keep_the_key_material(project, monkeypatch): assert "GCP_CREDENTIALS_BASE64__default" in services(project)["batch"]["environment"] assert "GCP_CREDENTIALS_BASE64__default" not in env_names(project) + + +# 鍵の実体を compose.yml へ直書きしている構成 (map 記法と list 記法) +INLINE_KEY_MAP_COMPOSE = """services: + dev: + image: alpine + environment: + GCP_CREDENTIALS_BASE64__default: eyJ9 + GCP_CREDENTIALS_BASE64__kkg: eyJ9 + TZ: Asia/Tokyo + volumes: + - x:/work + batch: + image: alpine + environment: + GCP_CREDENTIALS_BASE64__default: eyJ9 +volumes: + x: {} +""" + +INLINE_KEY_LIST_COMPOSE = """services: + dev: + image: alpine + environment: + - GCP_CREDENTIALS_BASE64__default=eyJ9 + - GCP_CREDENTIALS_BASE64__kkg + - TZ=Asia/Tokyo + volumes: + - x:/work +volumes: + x: {} +""" + + +@pytest.mark.parametrize("compose_text", [ + INLINE_KEY_MAP_COMPOSE, + INLINE_KEY_LIST_COMPOSE, +]) +def test_inline_key_material_is_dropped_from_dev(project, monkeypatch, compose_text): + """直書きされた鍵は列挙の絞り込みを迂回するので、生成物からも取り除く。 + + map 記法と list 記法(``KEY=VALUE`` と名前参照のみ)の両方を確認する。 + """ + (project / "compose.yml").write_text(compose_text) + monkeypatch.setenv("GCP_ACTIVE_PROFILE", "with") + monkeypatch.setenv("GCP_CREDENTIALS_BASE64__with", "eyJ9") + + generate_scaled_compose(1) + + names = env_names(project) + assert "GCP_CREDENTIALS_BASE64__default" not in names + assert "GCP_CREDENTIALS_BASE64__kkg" not in names + # 鍵と無関係な直書きの値は残す + assert "TZ" in names + + +def test_inline_key_material_of_non_dev_services_is_kept(project, monkeypatch): + """非 dev サービスの明示設定は消さない(既存方針の踏襲)。""" + (project / "compose.yml").write_text(INLINE_KEY_MAP_COMPOSE) + monkeypatch.setenv("GCP_ACTIVE_PROFILE", "with") + monkeypatch.setenv("GCP_CREDENTIALS_BASE64__with", "eyJ9") + + generate_scaled_compose(1) + + batch = services(project)["batch"]["environment"] + assert batch["GCP_CREDENTIALS_BASE64__default"] == "eyJ9" + + +def test_adc_drops_inline_key_material_including_the_active_profile(project, monkeypatch): + """adc では直書きされたアクティブプロファイルの鍵も取り除く。""" + (project / "compose.yml").write_text(INLINE_KEY_MAP_COMPOSE) + monkeypatch.setenv("GCP_AUTH_MODE", "adc") + + generate_scaled_compose(1) + + names = env_names(project) + assert not [name for name in names if "CREDENTIALS_BASE64" in name] + assert "TZ" in names