diff --git a/docs/user/environment-variables.md b/docs/user/environment-variables.md index 220a5b34..6ff24664 100644 --- a/docs/user/environment-variables.md +++ b/docs/user/environment-variables.md @@ -118,6 +118,40 @@ ADC を既定の経路にできます。鍵が要る場面のために切り替 認証方式の宣言なので、独自に鍵をマウントしている `batch` のような非 dev サービスが `environment:` や `env_file` で受け取っている 2 変数はそのまま残します。 +##### 鍵の実体を渡す範囲 + +`adc` が止めるのは **鍵をファイルへ書き出すこと**だけで、鍵を運ぶ base64 変数の配布までは +止まりません。名前が生成 compose の `environment:` に載っている限り、Compose が値を解決して +コンテナへ渡すため、`env` で中身が読めてしまいます。 + +そこで devbase は、コンテナ内で**使われない**鍵の変数も dev の列挙から外します。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 サービスの明示設定には触りません。 + +3 行目は後方互換キーの扱いです。entrypoint は `key` モードでアクティブプロファイルの鍵が +無いときだけこのキーへフォールバックするので、**そのときは供給源になるため外せません**。 +逆に言うと、プロファイル別キーへ移行済みのプロジェクトでは渡らなくなります。 + +これはアカウントグループ(`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..3dd26ade 100644 --- a/docs/user/google-auth.md +++ b/docs/user/google-auth.md @@ -535,6 +535,32 @@ 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` で中身が読めます。 + +| 変数 | `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` モードを引き起こして渡り続けます。別のアカウントグループの鍵を +> 持ち込みたくないプロジェクトでは、`env` に `GCP_AUTH_MODE=adc` を明示してください。 + **鍵が要るのはどういう場面か。** ユーザー認証では権限が足りない、あるいは人に紐づかない 実行主体が必要な場面です。たとえば本番データセットへの読み取りがサービスアカウントにしか 付与されていない場合や、コンテナ内から実行するバッチが特定の SA として動く必要がある場合です。 diff --git a/lib/devbase/env/gcp_auth.py b/lib/devbase/env/gcp_auth.py index af236298..8f88711a 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,73 @@ 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 変数のうちコンテナ内で使われないもの**を外す。 + + ``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) + # 呼び出し側が渡した順序を保ちつつ重複を除く + return tuple(dict.fromkeys(excluded)) diff --git a/lib/devbase/volume/compose.py b/lib/devbase/volume/compose.py index b129a1a1..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: @@ -626,17 +643,32 @@ 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] + # + # 候補は 3 か所から集める。機密の列挙・ホストの環境変数・元 compose.yml の + # dev への直書きである。どれか 1 つでも欠けると、そこに書かれた別プロファイル + # の鍵を外し損ねる。 + enumerated = [*secret_env_names, + *(global_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.key_only_env_names(auth_mode)) + dev_excluded=dev_excluded) # VS Code Server は再作成をまたいで保つためコンテナ 1 つに 1 本の named # volume を宛てる (PLAN36)。プロジェクトが自分で ~/.vscode-server を @@ -659,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/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..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 @@ -336,3 +344,210 @@ 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) + + +# 鍵の実体を 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