Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions docs/user/environment-variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name>/env
Expand Down
26 changes: 26 additions & 0 deletions docs/user/google-auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 として動く必要がある場合です。
Expand Down
78 changes: 77 additions & 1 deletion lib/devbase/env/gcp_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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__<active>`` が無いときだけ
``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))
Comment thread
takemi-ohama marked this conversation as resolved.
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))
64 changes: 51 additions & 13 deletions lib/devbase/volume/compose.py
Original file line number Diff line number Diff line change
Expand Up @@ -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`` から指定キーを**丸ごと**取り除く。

Expand All @@ -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:
Expand Down Expand Up @@ -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 を
Expand All @@ -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,
Expand Down
Loading
Loading