From 5ea80bab31e7d5ac48acbb2addbc36787e4b5529 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 17 Oct 2026 16:07:07 +0900 Subject: [PATCH 01/12] =?UTF-8?q?Fix:=20OpenBao=20=E3=81=B8=E3=81=AE?= =?UTF-8?q?=E6=8E=A5=E7=B6=9A=E3=82=92=20OS=20=E3=81=AE=E4=BF=A1=E9=A0=BC?= =?UTF-8?q?=E3=82=B9=E3=83=88=E3=82=A2=EF=BC=88truststore=EF=BC=89?= =?UTF-8?q?=E3=81=A7=E6=A4=9C=E8=A8=BC=E3=81=99=E3=82=8B=EF=BC=88#497?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TLS を傍受するプロキシの下で、既定の CA では検証に落ちてキャッシュへ落ちていた。 OpenBaoBackend._http の urlopen に truststore.SSLContext を渡す。プロセス全体へは 注入せず、truststore を読み込めないときは既定のコンテキストで続ける。 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NjbAm85zNM1QmoDYNiX5Dy --- CHANGELOG.md | 5 ++ docs/specifications/secret-backend.md | 11 +++ lib/devbase/env/openbao.py | 26 ++++++- pyproject.toml | 1 + tests/env/test_openbao.py | 108 ++++++++++++++++++++++++++ uv.lock | 11 +++ 6 files changed, 160 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fcf29be8..1d724b06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,11 @@ `~/.vscode-server/bin` を開発ユーザーが書ける形に揃えます。 ### Fixed +- **TLS を傍受するプロキシ(Netskope など)の下でも、ホストの devbase が OpenBao へ繋がるようにしました(#497)。** + OpenBao の証明書を OS の信頼ストア(macOS はキーチェーン)で検証します。これまでは Python の既定の CA で検証し、 + プロキシの CA を知らずに `OpenBao へ到達できないため、…のキャッシュを使います` と出てキャッシュへ落ち、書き込みも + できませんでした。`SSL_CERT_FILE` や別に置いた Python 3.12 で打つ回避は要りません。検証は緩めず、OpenBao 以外の + 通信(boto3 など)の検証は変わりません。依存に `truststore` を足しました。次の `devbase` の起動で自動で入ります。 - **`devbase up` / `devbase scale` の起動の待ちの時間切れの文言が、巡の回数と実際に待った秒数を出すようにしました(#374)。** 制限は 1 秒休む巡の回数(60 巡)で数え、docker の呼び出しにかかる時間を含まないため、実際に待つ時間は 60 秒より 長くなります。これまでの `timeout (60s) ...` は 60 秒で止まったと読めました。今は diff --git a/docs/specifications/secret-backend.md b/docs/specifications/secret-backend.md index ca16c063..7582894c 100644 --- a/docs/specifications/secret-backend.md +++ b/docs/specifications/secret-backend.md @@ -917,6 +917,14 @@ token をホストのディスクへ置かない決まりを守るためであ - KV v2 は値の中の `${OTHER_KEY}` を展開しない。参照記法は文字列のまま往復する - KV v2 にはコメント・空行の置き場が無く、`load_bytes()` が返すのは `KEY=VALUE` の並び である +- `https` の接続先の証明書は、OS の信頼ストア([truststore](https://truststore.readthedocs.io/)。 + macOS はキーチェーン、Windows は証明書ストア、Linux は OpenSSL の既定の CA)で検証する。4 つの経路 + (認証を含む)はすべて同じ SSL コンテキストを使う。TLS を傍受するプロキシの CA を OS に入れた端末で、 + `SSL_CERT_FILE` などを付けずに届くためである。検証は緩めない(証明書の検証とホスト名の照合を + 行い、Python 3.13 の `VERIFY_X509_STRICT` も外さない)。プロセス全体へは注入せず + (`truststore.inject_into_ssl()` を使わない)、boto3 など他の通信の検証は変わらない。`truststore` を + 読み込めないときは、Python の既定の SSL コンテキストで続ける。検証の失敗は到達できない失敗 + (`SecretUnreachableError`)として扱う パスの対応(`` は参照のグループの置き場のグループ名): @@ -1498,6 +1506,9 @@ groups: `--secret-id-stdin` へパイプで渡し、画面にも出さない - コンテナから見た接続先の置き換えは `http` の loopback に限る。`https` の URL は置き換えず、TLS の 検証の相手を変えない +- ホストの devbase は OpenBao の証明書を OS の信頼ストアで検証する(「OpenBao との契約」)。OS に入れた + CA を信頼するため、OS の信頼ストアへ CA を足せる者は接続先を偽れる。これは同じ端末のブラウザや curl と + 同じ前提である - `scope` を SHA-256 にするのは、`role_id` とパスを平文で残さないためである - 個人単位の機密はパスで分け、他人のパスを読む権限が無ければサーバが 403 を返す。宛先を 指すのは設定、渡してよいかを決めるのはサーバ(entity 名で絞るポリシー)という分担である diff --git a/lib/devbase/env/openbao.py b/lib/devbase/env/openbao.py index cace5bd6..560db645 100644 --- a/lib/devbase/env/openbao.py +++ b/lib/devbase/env/openbao.py @@ -1,6 +1,8 @@ """OpenBao (KV v2 + AppRole) を保存先にする ``SecretBackend`` 実装 (PLAN51) -REST を標準ライブラリの ``urllib.request`` で叩く (常時依存を増やさない)。 +REST を標準ライブラリの ``urllib.request`` で叩く。TLS の証明書は OS の信頼ストア +(``truststore``) で検証する (#497)。TLS を傍受するプロキシの CA を OS に入れた端末でも届くため。 +プロセス全体へは注入せず、OpenBao への接続にだけ使う。 使う経路は仕様「OpenBao との契約」の 4 つだけ: - ``POST /v1/auth/approle/login`` — ``role_id`` / ``secret_id`` を token へ交換 @@ -31,9 +33,11 @@ from __future__ import annotations +import functools import http.client import json import socket +import ssl import time from dataclasses import dataclass from pathlib import Path @@ -58,6 +62,23 @@ _CAS_MISMATCH = 'check-and-set' +@functools.lru_cache(maxsize=1) +def _tls_context() -> Optional[ssl.SSLContext]: + """OpenBao への HTTP に渡す SSL コンテキスト。OS の信頼ストアで検証する。 + + ``truststore.SSLContext`` は検証を緩めない (``CERT_REQUIRED``・ホスト名の照合)。 + ``truststore.inject_into_ssl()`` は使わない。プロセス全体の ``ssl`` を差し替え、 + boto3 など他の通信の検証まで変えてしまうため。``truststore`` を import できない + ときは ``None`` を返し、``urlopen`` は今までどおり既定のコンテキストで検証する。 + """ + try: + import truststore + except ImportError: + logger.debug("truststore を読み込めないため、既定の SSL コンテキストで OpenBao へ接続します") + return None + return truststore.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + + class SecretUnreachableError(SecretStoreError): """サーバへ到達できない、または応答を解釈できない (キャッシュへ落ちてよい)""" @@ -275,7 +296,8 @@ def _http(self, method: str, path: str, *, body: Optional[Dict[str, Any]] = None headers['X-Vault-Token'] = self._ensure_token() req = _urlrequest.Request(url, data=payload, method=method, headers=headers) try: - with _urlrequest.urlopen(req, timeout=self._settings.timeout_seconds) as resp: + with _urlrequest.urlopen(req, timeout=self._settings.timeout_seconds, + context=_tls_context()) as resp: raw = resp.read() except _urlerror.HTTPError as e: # 失敗応答の本文も最後まで受け取れないことがある (5xx の途中切れ)。 diff --git a/pyproject.toml b/pyproject.toml index a21d9ce4..7ed164a4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,6 +8,7 @@ dependencies = [ "pyrage>=1.2", "boto3>=1.34", "questionary>=2.1", + "truststore>=0.10", ] [dependency-groups] diff --git a/tests/env/test_openbao.py b/tests/env/test_openbao.py index f7c829a7..b0450054 100644 --- a/tests/env/test_openbao.py +++ b/tests/env/test_openbao.py @@ -6,6 +6,7 @@ import json from pathlib import Path from urllib import request +from urllib.parse import urlsplit import pytest @@ -545,3 +546,110 @@ def test_group_layout_reads_and_writes_the_group_paths(openbao_root, openbao): assert openbao.get('users/member01/acme/global') == {'B': '2'} assert {r.kv_path for r in openbao.received if r.kv_path} == { 'team/initech/projects/web', 'users/member01/acme/global'} + + +# --------------------------------------------------------------------------- +# TLS の検証 (#497)。OpenBao への HTTP は OS の信頼ストアで検証する +# --------------------------------------------------------------------------- + +@pytest.fixture +def recorded_urlopen(monkeypatch): + """``urlopen`` を包み、経路と ``context`` 引数を控えてから本物へ渡す""" + from devbase.env import openbao as openbao_module + + openbao_module._tls_context.cache_clear() + calls = [] + real = request.urlopen + + def urlopen(req, *args, **kwargs): + calls.append((req.get_method(), urlsplit(req.full_url).path, kwargs.get('context'))) + return real(req, *args, **kwargs) + + monkeypatch.setattr(request, 'urlopen', urlopen) + yield calls + openbao_module._tls_context.cache_clear() + + +def _exercise_every_route(store): + """認証・取得・保存・削除の 4 経路をすべて通す""" + store.save(GLOBAL, {'TOKEN': 'abc'}) + store.backend_for(GLOBAL).remove(GLOBAL) + + +def test_every_openbao_route_uses_the_truststore_context(store, openbao, recorded_urlopen): + import truststore + + _exercise_every_route(store) + + routes = {(method, path.split('/')[2] if path != '/v1/auth/approle/login' else 'login') + for method, path, _ in recorded_urlopen} + assert routes == {('POST', 'login'), ('GET', 'devbase'), ('POST', 'devbase'), + ('DELETE', 'devbase')} + contexts = {id(context) for _, _, context in recorded_urlopen} + assert len(contexts) == 1 + context = recorded_urlopen[0][2] + assert isinstance(context, truststore.SSLContext) + # 検証は緩めない + import ssl + assert context.verify_mode == ssl.CERT_REQUIRED + assert context.check_hostname is True + + +def test_truststore_is_not_injected_into_the_process(store, openbao, recorded_urlopen): + """boto3 など他の通信の検証を変えない (``inject_into_ssl`` を使わない)""" + import ssl + + original = ssl.SSLContext + _exercise_every_route(store) + + assert ssl.SSLContext is original + assert ssl.create_default_context().__class__ is original + + +def test_missing_truststore_falls_back_to_the_default_context(store, openbao, + recorded_urlopen, monkeypatch): + import sys + + monkeypatch.setitem(sys.modules, 'truststore', None) # import が ImportError になる + + _exercise_every_route(store) + + assert recorded_urlopen + assert all(context is None for _, _, context in recorded_urlopen) + assert store.load(GLOBAL) == {} + + +def test_certificate_verification_failure_is_unreachable(store, monkeypatch): + """証明書の検証の失敗は、これまでどおり到達できない失敗として扱う""" + import ssl + from urllib import error + + def urlopen(req, *args, **kwargs): + raise error.URLError(ssl.SSLCertVerificationError( + 1, '[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed')) + + monkeypatch.setattr(request, 'urlopen', urlopen) + + with pytest.raises(SecretUnreachableError) as exc: + store.load(GLOBAL) + message = str(exc.value) + assert message.startswith('OpenBao へ到達できません (') + assert 'CERTIFICATE_VERIFY_FAILED' in message + assert '\n 接続先: ' in message + + +def test_urlopen_is_called_only_from_the_openbao_http_helper(): + """``urlopen`` を呼ぶ経路は ``OpenBaoBackend._http`` だけ (増えたら同じ context を使わせる)""" + import ast + + lib = Path(__file__).resolve().parents[2] / 'lib' / 'devbase' + callers = [] + for source in sorted(lib.rglob('*.py')): + tree = ast.parse(source.read_text(encoding='utf-8')) + for node in ast.walk(tree): + if isinstance(node, ast.FunctionDef): + for inner in ast.walk(node): + if isinstance(inner, ast.Attribute) and inner.attr == 'urlopen' or \ + isinstance(inner, ast.Name) and inner.id == 'urlopen': + callers.append((source.relative_to(lib).as_posix(), node.name)) + assert sorted(set(callers)) == [('env/openbao.py', '_http')] diff --git a/uv.lock b/uv.lock index d0a9d1b8..d909b0a1 100644 --- a/uv.lock +++ b/uv.lock @@ -48,6 +48,7 @@ dependencies = [ { name = "pyrage" }, { name = "pyyaml" }, { name = "questionary" }, + { name = "truststore" }, ] [package.dev-dependencies] @@ -63,6 +64,7 @@ requires-dist = [ { name = "pyrage", specifier = ">=1.2" }, { name = "pyyaml", specifier = ">=6.0" }, { name = "questionary", specifier = ">=2.1" }, + { name = "truststore", specifier = ">=0.10" }, ] [package.metadata.requires-dev] @@ -368,6 +370,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, ] +[[package]] +name = "truststore" +version = "0.10.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/9f/c5201d42a484c061e528825fc8e2d565f5abd50a4ced6fb7d29c4ec99b2b/truststore-0.10.5.tar.gz", hash = "sha256:30d36967ccaded5cbb38d602c433f53600036c79d502f4533a49b60a03bbefcd", size = 28091, upload-time = "2026-10-12T22:27:31.808Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/e9/3a7820be2bb0fe53b6bc9c3be26d3d1158004e4c3ab953aa6840b955b1e9/truststore-0.10.5-py3-none-any.whl", hash = "sha256:9aaaedaefaf06d8b206278cf8b5012bc897f485a874503501e12d776df78951c", size = 19017, upload-time = "2026-10-12T22:27:30.377Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0" From aa5566d9eda8884caa884241196e0050ed1cab47 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 17 Oct 2026 16:18:00 +0900 Subject: [PATCH 02/12] =?UTF-8?q?Test:=20=E7=8F=BE=E7=8A=B6=E5=9B=BA?= =?UTF-8?q?=E5=AE=9A=E3=83=86=E3=82=B9=E3=83=88=E3=82=92=E8=B6=B3=E3=81=99?= =?UTF-8?q?=20=E2=80=94=20lib/devbase/env/openbao.py#OpenBaoBackend.fetch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Item-Id: I-001 Impl-Runtime: claude Impl-Model: default Co-Authored-By: Claude Opus 5.5 --- tests/env/test_openbao.py | 161 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 161 insertions(+) diff --git a/tests/env/test_openbao.py b/tests/env/test_openbao.py index b0450054..81e20c19 100644 --- a/tests/env/test_openbao.py +++ b/tests/env/test_openbao.py @@ -433,6 +433,167 @@ def test_write_does_not_touch_the_server_when_the_read_fails(store, openbao): assert not any(r.kv_path for r in openbao.requests_of('POST')) +# --------------------------------------------------------------------------- +# fetch の分岐 (現状固定) +# --------------------------------------------------------------------------- + +def _cached_after_outage(openbao_root, openbao, ref): + """サーバを落とし、新しい SecretStore がキャッシュから読む内容""" + openbao.stop() + return SecretStore(openbao_root).load(ref) + + +def test_fetch_returns_the_secrets_and_replaces_the_cache(openbao_root, store, openbao): + """現状固定: 成功した fetch は値を返し、キャッシュをその内容で置き換える。""" + openbao.put(TEAM_GLOBAL_PATH, {'A': '1'}) + backend = store.backend_for(GLOBAL) + + assert backend.fetch(GLOBAL) == {'A': '1'} + assert _cached_after_outage(openbao_root, openbao, GLOBAL) == {'A': '1'} + + +def test_fetch_reads_the_server_again_even_after_a_load(store, openbao): + """現状固定: fetch は控え (_seen) を使わず、毎回 GET する。""" + openbao.put(TEAM_GLOBAL_PATH, {'A': '1'}) + backend = store.backend_for(GLOBAL) + assert backend.load(GLOBAL) == {'A': '1'} + openbao.put(TEAM_GLOBAL_PATH, {'A': '2'}) + + assert backend.fetch(GLOBAL) == {'A': '2'} + assert backend.load(GLOBAL) == {'A': '2'} + assert len([r for r in openbao.requests_of('GET') if r.kv_path]) == 2 + + +def test_fetch_refreshes_the_version_used_by_the_next_save(store, openbao): + """現状固定: fetch で更新した版が次の書き込みの CAS になる。""" + openbao.put(TEAM_GLOBAL_PATH, {'A': '1'}) + backend = store.backend_for(GLOBAL) + backend.fetch(GLOBAL) + openbao.put(TEAM_GLOBAL_PATH, {'A': '2'}) + backend.fetch(GLOBAL) + + store.save(GLOBAL, {'A': '3'}) + + post = [r for r in openbao.requests_of('POST') if r.kv_path][0] + assert post.cas == 2 + + +def test_fetch_of_an_unwritten_path_is_empty_and_caches_empty(openbao_root, store, openbao): + """現状固定: 404 (未作成) は空を返し、キャッシュも空で置き換える。""" + openbao.put(TEAM_GLOBAL_PATH, {'A': '1'}) + backend = store.backend_for(GLOBAL) + assert backend.fetch(GLOBAL) == {'A': '1'} + openbao.get_status = 404 # 消えた (版の無い 404) + + assert backend.fetch(GLOBAL) == {} + openbao.get_status = None + assert _cached_after_outage(openbao_root, openbao, GLOBAL) == {} + + +def test_fetch_of_a_soft_deleted_path_keeps_the_current_version(store, openbao): + """現状固定: 論理削除の 404 は空を返し、本文の版を基準にする。""" + openbao.put(TEAM_GLOBAL_PATH, {'A': '1'}) + openbao.soft_delete(TEAM_GLOBAL_PATH) + + assert store.backend_for(GLOBAL).fetch(GLOBAL) == {} + store.save(GLOBAL, {'B': '1'}) + + post = [r for r in openbao.requests_of('POST') if r.kv_path][0] + assert post.cas == 1 + + +@pytest.mark.parametrize('body', [ + b'', b'{not json', b'\xff\xfe', b'[]', b'{"errors": []}', + b'{"data": {"metadata": {"version": "3"}}}', + b'{"data": {"metadata": {"version": true}}}', +], ids=['empty', 'malformed', 'undecodable', 'array', 'no-data', 'string-version', + 'bool-version']) +def test_fetch_404_without_a_usable_version_uses_version_zero(store, openbao, body): + """現状固定: 404 の本文から版を読めなければ空を返し、版 0 を基準にする。""" + openbao.get_status = 404 + openbao.get_body = body + + assert store.backend_for(GLOBAL).fetch(GLOBAL) == {} + openbao.get_status = None + openbao.get_body = None + store.save(GLOBAL, {'A': '1'}) + + post = [r for r in openbao.requests_of('POST') if r.kv_path][0] + assert post.cas == 0 + + +def test_fetch_403_is_an_auth_error_with_the_user_hint(store, openbao): + """現状固定: 403 は読む権限の SecretAuthError。パスと openbao.user の手掛かりを含む。""" + openbao.forbidden_prefixes = ['team/'] + + with pytest.raises(SecretAuthError) as exc: + store.backend_for(GLOBAL).fetch(GLOBAL) + + message = str(exc.value) + assert message.startswith('OpenBao でこの参照を読む権限がありません') + assert 'HTTP 403' in message + assert f'接続先: {openbao.url}' in message + assert 'パス: ' in message + assert 'backend.yml の openbao.user (現在: member01) が本人の識別子と違う可能性があります' \ + in message + assert exc.value.__cause__ is None and exc.value.__suppress_context__ is True + + +@pytest.mark.parametrize('status', [400, 401, 405, 500, 503]) +def test_fetch_other_statuses_are_unreachable_with_the_status(store, openbao, status): + """現状固定: 404・403 以外の失敗応答は HTTP の番号付きの SecretUnreachableError。""" + openbao.get_status = status + + with pytest.raises(SecretUnreachableError) as exc: + store.backend_for(GLOBAL).fetch(GLOBAL) + + message = str(exc.value) + assert message.startswith('OpenBao へ到達できません (') + assert f'HTTP {status})' in message + assert f'接続先: {openbao.url}' in message + assert exc.value.__suppress_context__ is True + + +def test_fetch_failure_does_not_touch_the_basis_or_the_cache(openbao_root, store, openbao): + """現状固定: 失敗した fetch は前の基準とキャッシュを残す。""" + openbao.put(TEAM_GLOBAL_PATH, {'A': '1'}) + backend = store.backend_for(GLOBAL) + backend.fetch(GLOBAL) + openbao.get_status = 500 + + with pytest.raises(SecretUnreachableError): + backend.fetch(GLOBAL) + + assert backend.load(GLOBAL) == {'A': '1'} + assert _cached_after_outage(openbao_root, openbao, GLOBAL) == {'A': '1'} + + +@pytest.mark.parametrize('metadata', [ + '', ', "metadata": {}', ', "metadata": {"version": "1"}', ', "metadata": {"version": true}', + ', "metadata": []', +], ids=['no-metadata', 'no-version', 'string-version', 'bool-version', 'array-metadata']) +def test_fetch_without_a_version_is_unreadable(store, openbao, metadata): + """現状固定: 200 で data.metadata.version (整数) が無ければ解釈できない応答。""" + openbao.get_body = ('{"data": {"data": {"A": "1"}%s}}' % metadata).encode() + + with pytest.raises(SecretUnreachableError) as exc: + store.backend_for(GLOBAL).fetch(GLOBAL) + + message = str(exc.value) + assert message.startswith('OpenBao の応答を解釈できません (') + assert 'data.metadata.version がありません' in message + + +def test_fetch_checks_the_secrets_before_the_version(store, openbao): + """現状固定: data.data と版の両方が不正なら、data.data の不正を先に報じる。""" + openbao.get_body = b'{"data": {"data": "nope"}}' + + with pytest.raises(SecretUnreachableError) as exc: + store.backend_for(GLOBAL).fetch(GLOBAL) + + assert 'data.data が辞書ではありません' in str(exc.value) + + # --------------------------------------------------------------------------- # backend test # --------------------------------------------------------------------------- From 3695a980bc955195f885ec4860443f57c54278f5 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 17 Oct 2026 16:19:15 +0900 Subject: [PATCH 03/12] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=20lib/devbase/env/openbao.py#OpenBaoBackend.fetch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 404 の応答の本文から現在の版を取り出す段階を _version_from_404 へ抽出した。 fetch は 404 のときにこのヘルパの版で基準を控えるだけになる。 Item-Id: I-001 Impl-Runtime: claude Impl-Model: claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- lib/devbase/env/openbao.py | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/lib/devbase/env/openbao.py b/lib/devbase/env/openbao.py index 560db645..975cdaf0 100644 --- a/lib/devbase/env/openbao.py +++ b/lib/devbase/env/openbao.py @@ -360,6 +360,20 @@ def _version_of(data: Any) -> Optional[int]: version = _dict_get(metadata, 'version') return version if isinstance(version, int) and not isinstance(version, bool) else None + @classmethod + def _version_from_404(cls, body: bytes) -> int: + """404 の応答の本文から現在の版を取り出す (無ければ 0)。 + + 未作成、または最新版が論理削除されている。後者は本文に現在の版が入る + (版 0 を送ると不一致になる) + """ + try: + data = json.loads(body.decode('utf-8')) if body else {} + except (ValueError, UnicodeDecodeError): + data = {} + version = cls._version_of(data) + return version if version is not None else 0 + def _parse_secrets(self, ref: SecretRef, data: Any) -> Dict[str, str]: inner = _dict_get(data, 'data') values = _dict_get(inner, 'data') @@ -386,14 +400,7 @@ def fetch(self, ref: SecretRef) -> Dict[str, str]: data = self._http('GET', self._kv_path('data', ref)) except _HttpStatus as e: if e.status == 404: - # 未作成、または最新版が論理削除されている。後者は本文に現在の版が入る - # (版 0 を送ると不一致になる) - try: - body = json.loads(e.body.decode('utf-8')) if e.body else {} - except (ValueError, UnicodeDecodeError): - body = {} - version = self._version_of(body) - self._remember(ref, {}, version if version is not None else 0) + self._remember(ref, {}, self._version_from_404(e.body)) return {} if e.status == 403: raise self._forbidden( From 437293f5d2a6503e1a36fb3710a2c0a24ab80127 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 17 Oct 2026 16:19:35 +0900 Subject: [PATCH 04/12] =?UTF-8?q?Refactor:=20rename=20=E2=80=94=20lib/devb?= =?UTF-8?q?ase/env/openbao.py#=5FHttpStatus?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HTTP の失敗応答を表す内部の例外 _HttpStatus を、同じファイルのほかの例外に そろえて _HttpStatusError へ改名した(定義・raise 1 か所・except 4 か所)。 Item-Id: I-002 Impl-Runtime: claude Impl-Model: claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- lib/devbase/env/openbao.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/lib/devbase/env/openbao.py b/lib/devbase/env/openbao.py index 975cdaf0..3f6d83c4 100644 --- a/lib/devbase/env/openbao.py +++ b/lib/devbase/env/openbao.py @@ -104,7 +104,7 @@ def _dict_get(value: Any, key: str) -> Any: return value.get(key) if isinstance(value, dict) else None -class _HttpStatus(Exception): +class _HttpStatusError(Exception): """HTTP の失敗応答 (内部用)""" def __init__(self, status: int, body: bytes): @@ -238,7 +238,7 @@ def login(self) -> None: data = self._http('POST', LOGIN_PATH, body={'role_id': creds.role_id, 'secret_id': creds.secret_id}, auth=False) - except _HttpStatus as e: + except _HttpStatusError as e: # 400 (invalid role or secret) は secret_id の失効・書き間違い、403 は利用者 # (entity) の無効化。どちらも資格の取り消しで、キャッシュへ落ちてはいけない if e.status in (400, 403): @@ -308,7 +308,7 @@ def _http(self, method: str, path: str, *, body: Optional[Dict[str, Any]] = None raise SecretUnreachableError( f"OpenBao へ到達できません (HTTP {e.code} の応答を最後まで受け取れません: " f"{read_error})\n 接続先: {self.url}") from None - raise _HttpStatus(e.code, body) from None + raise _HttpStatusError(e.code, body) from None except (_urlerror.URLError, socket.timeout, ConnectionError, OSError, http.client.HTTPException) as e: # 本文の途中で切られたとき (IncompleteRead) も「応答を最後まで受け取れなかった」 @@ -398,7 +398,7 @@ def fetch(self, ref: SecretRef) -> Dict[str, str]: """ try: data = self._http('GET', self._kv_path('data', ref)) - except _HttpStatus as e: + except _HttpStatusError as e: if e.status == 404: self._remember(ref, {}, self._version_from_404(e.body)) return {} @@ -474,7 +474,7 @@ def save(self, ref: SecretRef, data: Dict[str, str]) -> Path: body = {'options': {'cas': version}, 'data': dict(data)} try: response = self._http('POST', self._kv_path('data', ref), body=body) - except _HttpStatus as e: + except _HttpStatusError as e: if e.status == 400 and any(_CAS_MISMATCH in msg for msg in e.errors()): # 読んだ内容がもう現物と違う。控えを消して、やり直しを促す self._forget(ref) @@ -525,7 +525,7 @@ def remove(self, ref: SecretRef) -> bool: return False try: self._http('DELETE', self._kv_path('metadata', ref)) - except _HttpStatus as e: + except _HttpStatusError as e: if e.status == 403: raise self._forbidden(ref, 'を削除する', show_path=False) from None self._forget(ref) From 215e32777e843659c1e559e5ab42ea7b1381fbd4 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 17 Oct 2026 16:20:25 +0900 Subject: [PATCH 05/12] =?UTF-8?q?Refactor:=20consolidate=5Fduplication=20?= =?UTF-8?q?=E2=80=94=20lib/devbase/env/openbao.py#OpenBaoBackend.save?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 応答の版が整数か (真偽値でない) を確かめる規則を _as_version へまとめ、 _version_of と save の新しい版の読み取りの両方から使う。 Item-Id: I-004 Impl-Runtime: claude Impl-Model: claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- lib/devbase/env/openbao.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/lib/devbase/env/openbao.py b/lib/devbase/env/openbao.py index 3f6d83c4..4c1ac1ec 100644 --- a/lib/devbase/env/openbao.py +++ b/lib/devbase/env/openbao.py @@ -104,6 +104,11 @@ def _dict_get(value: Any, key: str) -> Any: return value.get(key) if isinstance(value, dict) else None +def _as_version(value: Any) -> Optional[int]: + """応答の版として読める値 (整数で真偽値でない) ならその値、ほかは ``None``""" + return value if isinstance(value, int) and not isinstance(value, bool) else None + + class _HttpStatusError(Exception): """HTTP の失敗応答 (内部用)""" @@ -358,7 +363,7 @@ def _version_of(data: Any) -> Optional[int]: inner = _dict_get(data, 'data') metadata = _dict_get(inner, 'metadata') version = _dict_get(metadata, 'version') - return version if isinstance(version, int) and not isinstance(version, bool) else None + return _as_version(version) @classmethod def _version_from_404(cls, body: bytes) -> int: @@ -503,8 +508,8 @@ def save(self, ref: SecretRef, data: Dict[str, str]) -> Path: self._forget(ref) raise inner = _dict_get(response, 'data') - new_version = _dict_get(inner, 'version') - if not isinstance(new_version, int) or isinstance(new_version, bool): + new_version = _as_version(_dict_get(inner, 'version')) + if new_version is None: self._forget(ref) raise self._unreadable(ref, '保存の応答に data.version がありません') # 応答の版が次の書き込みの基準になる (同じ実行で同じ参照を再び書くとき、 From ef985f60b416fe8d1fe6da32c4e0a4e26cd36ce1 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 17 Oct 2026 16:21:10 +0900 Subject: [PATCH 06/12] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=20lib/devbase/env/openbao.py#OpenBaoBackend?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 失敗の文言の末尾へ「接続先: 」の行を足す処理を _at_server へ抽出し、 接続先の行で終わる文言 (login・_http・_unreachable・_unreadable・_forbidden) を このヘルパで組む。接続先の後ろに案内やパスが続く文言はそのまま残した。 Item-Id: I-005 Impl-Runtime: claude Impl-Model: claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- lib/devbase/env/openbao.py | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/lib/devbase/env/openbao.py b/lib/devbase/env/openbao.py index 4c1ac1ec..a7dae1c3 100644 --- a/lib/devbase/env/openbao.py +++ b/lib/devbase/env/openbao.py @@ -175,6 +175,10 @@ def settings(self): def url(self) -> str: return self._settings.url + def _at_server(self, message: str) -> str: + """失敗の文言の末尾へ接続先の行を足す""" + return f"{message}\n 接続先: {self.url}" + def _check_group(self, ref: SecretRef) -> None: """参照のグループが設定のレイアウトと合うことを確かめる (PLAN56)。 @@ -253,12 +257,11 @@ def login(self) -> None: " この端末の secret_id が失効していないか、利用者が無効化されていないか" "確認してください") from None raise SecretUnreachableError( - f"OpenBao へ到達できません (HTTP {e.status})\n 接続先: {self.url}") from None + self._at_server(f"OpenBao へ到達できません (HTTP {e.status})")) from None auth = _dict_get(data, 'auth') token = _dict_get(auth, 'client_token') if not isinstance(token, str) or not token: - raise SecretUnreachableError( - f"OpenBao の認証応答を解釈できません\n 接続先: {self.url}") + raise SecretUnreachableError(self._at_server("OpenBao の認証応答を解釈できません")) lease = auth.get('lease_duration') ttl = float(lease) if isinstance(lease, (int, float)) and lease > 0 else 3600.0 self._token = token @@ -310,33 +313,31 @@ def _http(self, method: str, path: str, *, body: Optional[Dict[str, Any]] = None try: body = e.read() or b'' except (OSError, http.client.HTTPException) as read_error: - raise SecretUnreachableError( + raise SecretUnreachableError(self._at_server( f"OpenBao へ到達できません (HTTP {e.code} の応答を最後まで受け取れません: " - f"{read_error})\n 接続先: {self.url}") from None + f"{read_error})")) from None raise _HttpStatusError(e.code, body) from None except (_urlerror.URLError, socket.timeout, ConnectionError, OSError, http.client.HTTPException) as e: # 本文の途中で切られたとき (IncompleteRead) も「応答を最後まで受け取れなかった」 # 不達として扱う - raise SecretUnreachableError( - f"OpenBao へ到達できません ({e.reason if hasattr(e, 'reason') else e})\n" - f" 接続先: {self.url}") from None + raise SecretUnreachableError(self._at_server( + f"OpenBao へ到達できません ({e.reason if hasattr(e, 'reason') else e})")) from None if not raw: return {} try: return json.loads(raw.decode('utf-8')) except ValueError: - raise SecretUnreachableError( - f"OpenBao の応答を JSON として読めません ({method} {path})\n" - f" 接続先: {self.url}") from None + raise SecretUnreachableError(self._at_server( + f"OpenBao の応答を JSON として読めません ({method} {path})")) from None def _unreachable(self, status: int, ref: SecretRef) -> SecretUnreachableError: return SecretUnreachableError( - f"OpenBao へ到達できません ({ref.label()}: HTTP {status})\n 接続先: {self.url}") + self._at_server(f"OpenBao へ到達できません ({ref.label()}: HTTP {status})")) def _unreadable(self, ref: SecretRef, why: str) -> SecretUnreachableError: return SecretUnreachableError( - f"OpenBao の応答を解釈できません ({ref.label()}: {why})\n 接続先: {self.url}") + self._at_server(f"OpenBao の応答を解釈できません ({ref.label()}: {why})")) def _forbidden(self, ref: SecretRef, action: str, *, hint: str = '', show_path: bool = True) -> SecretAuthError: @@ -346,9 +347,8 @@ def _forbidden(self, ref: SecretRef, action: str, *, ``を削除する``)。``show_path`` が真ならパス行を足す。``hint`` があれば 末尾へ足す (削除は付けない)。 """ - message = ( - f"OpenBao でこの参照{action}権限がありません ({ref.label()}: HTTP 403)\n" - f" 接続先: {self.url}") + message = self._at_server( + f"OpenBao でこの参照{action}権限がありません ({ref.label()}: HTTP 403)") if show_path: message += f"\n パス: {self.display_path(ref)}" if hint: From 10b39ba0627c7fe37755726d97e466d56463e898 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 17 Oct 2026 16:22:11 +0900 Subject: [PATCH 07/12] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=20tests/env/test=5Fopenbao.py#test=5Furlopen=5Fis=5Fcalled=5Fo?= =?UTF-8?q?nly=5Ffrom=5Fthe=5Fopenbao=5Fhttp=5Fhelper?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 関数が urlopen を属性か名前で参照するかの判定を _refers_to_urlopen へ抽出し、 演算子の優先順位に頼っていた条件を 2 つの if に分けた。テスト本体は ファイルと関数の列挙だけになる。補助関数の判定 (属性・名前・入れ子・無関係な名前) を 小さな AST で確かめるテストを足した。 Item-Id: I-007 Impl-Runtime: claude Impl-Model: claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- tests/env/test_openbao.py | 33 +++++++++++++++++++++++++++------ 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/tests/env/test_openbao.py b/tests/env/test_openbao.py index 81e20c19..82513864 100644 --- a/tests/env/test_openbao.py +++ b/tests/env/test_openbao.py @@ -799,6 +799,30 @@ def urlopen(req, *args, **kwargs): assert '\n 接続先: ' in message +def _refers_to_urlopen(function) -> bool: + """関数の本体 (入れ子の関数を含む) が ``urlopen`` を属性か名前で参照するか""" + import ast + + for inner in ast.walk(function): + if isinstance(inner, ast.Attribute) and inner.attr == 'urlopen': + return True + if isinstance(inner, ast.Name) and inner.id == 'urlopen': + return True + return False + + +def test_refers_to_urlopen_sees_attributes_names_and_nested_functions(): + import ast + + def function_of(source): + return ast.parse(source).body[0] + + assert _refers_to_urlopen(function_of('def f():\n request.urlopen(x)\n')) + assert _refers_to_urlopen(function_of('def f():\n urlopen(x)\n')) + assert _refers_to_urlopen(function_of('def f():\n def g():\n urlopen(x)\n')) + assert not _refers_to_urlopen(function_of('def f():\n request.urlopen_later(x)\n')) + + def test_urlopen_is_called_only_from_the_openbao_http_helper(): """``urlopen`` を呼ぶ経路は ``OpenBaoBackend._http`` だけ (増えたら同じ context を使わせる)""" import ast @@ -807,10 +831,7 @@ def test_urlopen_is_called_only_from_the_openbao_http_helper(): callers = [] for source in sorted(lib.rglob('*.py')): tree = ast.parse(source.read_text(encoding='utf-8')) - for node in ast.walk(tree): - if isinstance(node, ast.FunctionDef): - for inner in ast.walk(node): - if isinstance(inner, ast.Attribute) and inner.attr == 'urlopen' or \ - isinstance(inner, ast.Name) and inner.id == 'urlopen': - callers.append((source.relative_to(lib).as_posix(), node.name)) + callers += [(source.relative_to(lib).as_posix(), node.name) + for node in ast.walk(tree) + if isinstance(node, ast.FunctionDef) and _refers_to_urlopen(node)] assert sorted(set(callers)) == [('env/openbao.py', '_http')] From 7a781b8d408125a8040e19e7e31d797877881d92 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 17 Oct 2026 16:22:33 +0900 Subject: [PATCH 08/12] =?UTF-8?q?Refactor:=20introduce=5Fnamed=5Fconstant?= =?UTF-8?q?=20=E2=80=94=20lib/devbase/env/openbao.py#OpenBaoBackend?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit login・fetch・save・remove に生のまま散っていた HTTP の状態コード (400・403・404) を _HTTP_BAD_REQUEST・_HTTP_FORBIDDEN・_HTTP_NOT_FOUND とログインの拒否の組 _LOGIN_REJECT_STATUSES に置き換えた。4xx の範囲の判定はそのまま残した。 Item-Id: I-008 Impl-Runtime: claude Impl-Model: claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- lib/devbase/env/openbao.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/lib/devbase/env/openbao.py b/lib/devbase/env/openbao.py index a7dae1c3..d84f1ed2 100644 --- a/lib/devbase/env/openbao.py +++ b/lib/devbase/env/openbao.py @@ -61,6 +61,13 @@ #: check-and-set の不一致を表す、応答の ``errors`` に含まれる語 _CAS_MISMATCH = 'check-and-set' +#: 意味を持つ HTTP の状態コード +_HTTP_BAD_REQUEST = 400 +_HTTP_FORBIDDEN = 403 +_HTTP_NOT_FOUND = 404 +#: ログインの拒否 (secret_id の失効・書き間違いと、利用者の無効化) +_LOGIN_REJECT_STATUSES = (_HTTP_BAD_REQUEST, _HTTP_FORBIDDEN) + @functools.lru_cache(maxsize=1) def _tls_context() -> Optional[ssl.SSLContext]: @@ -250,7 +257,7 @@ def login(self) -> None: except _HttpStatusError as e: # 400 (invalid role or secret) は secret_id の失効・書き間違い、403 は利用者 # (entity) の無効化。どちらも資格の取り消しで、キャッシュへ落ちてはいけない - if e.status in (400, 403): + if e.status in _LOGIN_REJECT_STATUSES: raise SecretAuthError( f"OpenBao の資格を確認できません (認証が拒まれました: HTTP {e.status})\n" f" 接続先: {self.url}\n" @@ -404,10 +411,10 @@ def fetch(self, ref: SecretRef) -> Dict[str, str]: try: data = self._http('GET', self._kv_path('data', ref)) except _HttpStatusError as e: - if e.status == 404: + if e.status == _HTTP_NOT_FOUND: self._remember(ref, {}, self._version_from_404(e.body)) return {} - if e.status == 403: + if e.status == _HTTP_FORBIDDEN: raise self._forbidden( ref, 'を読む', hint=f" backend.yml の openbao.user (現在: {self._settings.user}) が" @@ -480,7 +487,7 @@ def save(self, ref: SecretRef, data: Dict[str, str]) -> Path: try: response = self._http('POST', self._kv_path('data', ref), body=body) except _HttpStatusError as e: - if e.status == 400 and any(_CAS_MISMATCH in msg for msg in e.errors()): + if e.status == _HTTP_BAD_REQUEST and any(_CAS_MISMATCH in msg for msg in e.errors()): # 読んだ内容がもう現物と違う。控えを消して、やり直しを促す self._forget(ref) raise SecretConflictError( @@ -488,7 +495,7 @@ def save(self, ref: SecretRef, data: Dict[str, str]) -> Path: f"(版 {version} が現在の版と一致しません)\n" f" パス: {self.display_path(ref)}\n" " もう一度読み直してから同じ操作をやり直してください") from None - if e.status == 403: + if e.status == _HTTP_FORBIDDEN: # ログインは通っている。資格の取り消しではなく、このパスへ書く権限が無い raise self._forbidden( ref, 'への書き込み', @@ -531,7 +538,7 @@ def remove(self, ref: SecretRef) -> bool: try: self._http('DELETE', self._kv_path('metadata', ref)) except _HttpStatusError as e: - if e.status == 403: + if e.status == _HTTP_FORBIDDEN: raise self._forbidden(ref, 'を削除する', show_path=False) from None self._forget(ref) raise self._unreachable(e.status, ref) from None From 4b468df956c8bc72ee6ca86e4d932ae262b757d9 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 17 Oct 2026 16:23:07 +0900 Subject: [PATCH 09/12] =?UTF-8?q?Refactor:=20extract=5Ftest=5Fhelper=20?= =?UTF-8?q?=E2=80=94=20tests/env/test=5Fopenbao.py#test=5Fsave=5Fsends=5Ft?= =?UTF-8?q?he=5Fwhole=5Fdictionary=5Fin=5Fone=5Fpost=5Fwith=5Fthe=5Fread?= =?UTF-8?q?=5Fversion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit KV への POST だけを取り出す内包表記を _kv_posts(openbao) へまとめ、 書き方の揺れ (kv_path is not None と真偽) も 1 つにそろえた。 Item-Id: I-009 Impl-Runtime: claude Impl-Model: claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- tests/env/test_openbao.py | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/tests/env/test_openbao.py b/tests/env/test_openbao.py index 82513864..f39dd2e0 100644 --- a/tests/env/test_openbao.py +++ b/tests/env/test_openbao.py @@ -32,6 +32,11 @@ USER_WEB_PATH = 'users/member01/projects/web' +def _kv_posts(openbao): + """ログインの POST を除いた、KV への POST""" + return [r for r in openbao.requests_of('POST') if r.kv_path] + + @pytest.mark.parametrize('body', [ [], {}, {'auth': None}, {'auth': {}}, {'auth': {'client_token': 123}}, {'auth': {'client_token': ''}}, @@ -148,8 +153,7 @@ def test_save_sends_the_whole_dictionary_in_one_post_with_the_read_version(store store.save(GLOBAL, {'SAME': '1', 'CHANGED': 'new', 'ADDED': 'a'}) - posts = openbao.requests_of('POST') - posts = [r for r in posts if r.kv_path is not None] + posts = _kv_posts(openbao) assert len(posts) == 1 assert posts[0].cas == 1 assert posts[0].body['data'] == {'SAME': '1', 'CHANGED': 'new', 'ADDED': 'a'} @@ -164,14 +168,14 @@ def test_save_without_a_prior_read_fetches_the_version_first(store, openbao): store.save(GLOBAL, {'A': '3'}) assert len(openbao.requests_of('GET')) == 1 - post = [r for r in openbao.requests_of('POST') if r.kv_path][0] + post = _kv_posts(openbao)[0] assert post.cas == 2 def test_save_to_an_unwritten_path_uses_version_zero(store, openbao): store.save(GLOBAL, {'A': '1'}) - post = [r for r in openbao.requests_of('POST') if r.kv_path][0] + post = _kv_posts(openbao)[0] assert post.cas == 0 assert openbao.version_of(TEAM_GLOBAL_PATH) == 1 @@ -184,7 +188,7 @@ def test_save_after_a_soft_delete_uses_the_current_version_not_zero(store, openb assert store.load(GLOBAL) == {} store.save(GLOBAL, {'B': '1'}) - post = [r for r in openbao.requests_of('POST') if r.kv_path][0] + post = _kv_posts(openbao)[0] assert post.cas == 2 assert openbao.get(TEAM_GLOBAL_PATH) == {'B': '1'} @@ -221,7 +225,7 @@ def test_a_second_save_in_the_same_store_uses_the_version_returned_by_the_first( store.save(GLOBAL, {'A': '1'}) store.save(GLOBAL, {'A': '2'}) - posts = [r for r in openbao.requests_of('POST') if r.kv_path] + posts = _kv_posts(openbao) assert [p.cas for p in posts] == [0, 1] assert openbao.get(TEAM_GLOBAL_PATH) == {'A': '2'} assert len(openbao.requests_of('GET')) == 1 # 1 度目の保存前だけ @@ -257,7 +261,7 @@ def test_path_segments_are_percent_encoded_in_the_url(openbao_root, openbao): store.save(USER_GLOBAL, {'A': '1'}) - post = [r for r in openbao.requests_of('POST') if r.kv_path][0] + post = _kv_posts(openbao)[0] assert '%20' in post.path assert post.kv_path == 'users/member 01/global' @@ -430,7 +434,7 @@ def test_write_does_not_touch_the_server_when_the_read_fails(store, openbao): with pytest.raises(SecretStoreError): store.save(GLOBAL, {'A': '1'}) - assert not any(r.kv_path for r in openbao.requests_of('POST')) + assert not _kv_posts(openbao) # --------------------------------------------------------------------------- @@ -474,7 +478,7 @@ def test_fetch_refreshes_the_version_used_by_the_next_save(store, openbao): store.save(GLOBAL, {'A': '3'}) - post = [r for r in openbao.requests_of('POST') if r.kv_path][0] + post = _kv_posts(openbao)[0] assert post.cas == 2 @@ -498,7 +502,7 @@ def test_fetch_of_a_soft_deleted_path_keeps_the_current_version(store, openbao): assert store.backend_for(GLOBAL).fetch(GLOBAL) == {} store.save(GLOBAL, {'B': '1'}) - post = [r for r in openbao.requests_of('POST') if r.kv_path][0] + post = _kv_posts(openbao)[0] assert post.cas == 1 @@ -518,7 +522,7 @@ def test_fetch_404_without_a_usable_version_uses_version_zero(store, openbao, bo openbao.get_body = None store.save(GLOBAL, {'A': '1'}) - post = [r for r in openbao.requests_of('POST') if r.kv_path][0] + post = _kv_posts(openbao)[0] assert post.cas == 0 From eb4b8a40a42b67d224847d7429cab570906e64c9 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 17 Oct 2026 16:27:52 +0900 Subject: [PATCH 10/12] =?UTF-8?q?Refactor:=20consolidate=5Fduplication=20?= =?UTF-8?q?=E2=80=94=20lib/devbase/env/openbao.py#OpenBaoBackend.=5Funreac?= =?UTF-8?q?hable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 接続先の行を文面の途中に手で組んでいた 3 か所 (login の認証拒否・_basis_for_write・ save の 4xx 拒否) を _at_server 経由にした。_at_server は接続先の行の後へ続く行を 受け取れるようにし、文面は変えていない。 Item-Id: I-010 Impl-Runtime: claude Impl-Model: claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- lib/devbase/env/openbao.py | 27 ++++++++++++--------------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/lib/devbase/env/openbao.py b/lib/devbase/env/openbao.py index d84f1ed2..67ecd2dd 100644 --- a/lib/devbase/env/openbao.py +++ b/lib/devbase/env/openbao.py @@ -182,9 +182,9 @@ def settings(self): def url(self) -> str: return self._settings.url - def _at_server(self, message: str) -> str: - """失敗の文言の末尾へ接続先の行を足す""" - return f"{message}\n 接続先: {self.url}" + def _at_server(self, message: str, *details: str) -> str: + """失敗の文言へ接続先の行を足す。``details`` は接続先の行の後へ 1 行ずつ続ける""" + return '\n'.join([message, f" 接続先: {self.url}", *details]) def _check_group(self, ref: SecretRef) -> None: """参照のグループが設定のレイアウトと合うことを確かめる (PLAN56)。 @@ -258,11 +258,10 @@ def login(self) -> None: # 400 (invalid role or secret) は secret_id の失効・書き間違い、403 は利用者 # (entity) の無効化。どちらも資格の取り消しで、キャッシュへ落ちてはいけない if e.status in _LOGIN_REJECT_STATUSES: - raise SecretAuthError( - f"OpenBao の資格を確認できません (認証が拒まれました: HTTP {e.status})\n" - f" 接続先: {self.url}\n" + raise SecretAuthError(self._at_server( + f"OpenBao の資格を確認できません (認証が拒まれました: HTTP {e.status})", " この端末の secret_id が失効していないか、利用者が無効化されていないか" - "確認してください") from None + "確認してください")) from None raise SecretUnreachableError( self._at_server(f"OpenBao へ到達できません (HTTP {e.status})")) from None auth = _dict_get(data, 'auth') @@ -473,11 +472,10 @@ def _basis_for_write(self, ref: SecretRef) -> int: self.fetch(ref) basis = self._seen[ref] if basis.version is None: - raise SecretUnreachableError( - f"OpenBao へ到達できないため、{ref.label()}へ書き込めません\n" - f" 接続先: {self.url}\n" + raise SecretUnreachableError(self._at_server( + f"OpenBao へ到達できないため、{ref.label()}へ書き込めません", " 控えから読んだ内容は書き戻しません (不達の間の他の利用者の更新を" - "上書きしないため)。サーバへ届く状態でやり直してください") + "上書きしないため)。サーバへ届く状態でやり直してください")) return basis.version def save(self, ref: SecretRef, data: Dict[str, str]) -> Path: @@ -503,10 +501,9 @@ def save(self, ref: SecretRef, data: Dict[str, str]) -> Path: "利用者だけです") from None if 400 <= e.status < 500: # サーバが拒んだと確定した。サーバは変わっておらず、控えはそのまま正しい - raise SecretRefusedError( - f"OpenBao が書き込みを拒みました ({ref.label()}: HTTP {e.status})\n" - f" 接続先: {self.url}\n" - f" パス: {self.display_path(ref)}") from None + raise SecretRefusedError(self._at_server( + f"OpenBao が書き込みを拒みました ({ref.label()}: HTTP {e.status})", + f" パス: {self.display_path(ref)}")) from None # 5xx はサーバが更新を確定した後で応答だけが失われた可能性がある self._forget(ref) raise self._unreachable(e.status, ref) from None From 641c3f8d73b5e6ca3058f60fd2ab72b6f9838723 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 17 Oct 2026 16:28:51 +0900 Subject: [PATCH 11/12] =?UTF-8?q?Refactor:=20consolidate=5Fduplication=20?= =?UTF-8?q?=E2=80=94=20lib/devbase/env/openbao.py#=5FHttpStatus.errors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 共通化の前に、書き込みの 400 の本文 (空・不正な JSON・不正な UTF-8・配列・ errors がリストでない・要素が文字列でない) の扱いを現状固定テストで押さえる。 Item-Id: I-011 Impl-Runtime: claude Impl-Model: claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- tests/env/test_openbao.py | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/tests/env/test_openbao.py b/tests/env/test_openbao.py index f39dd2e0..7e1ef403 100644 --- a/tests/env/test_openbao.py +++ b/tests/env/test_openbao.py @@ -367,6 +367,45 @@ def test_other_4xx_on_write_is_a_refusal_not_an_outage(store, openbao, status): assert openbao.get(TEAM_GLOBAL_PATH) == {'A': '1'} +def _write_fails_with(monkeypatch, status, body): + """KV への POST だけを ``status`` と本文 ``body`` の失敗応答にする""" + from urllib import error + + real = request.urlopen + + def urlopen(req, *args, **kwargs): + if req.get_method() == 'POST' and '/data/' in urlsplit(req.full_url).path: + raise error.HTTPError(req.full_url, status, 'error', {}, io.BytesIO(body)) + return real(req, *args, **kwargs) + + monkeypatch.setattr(request, 'urlopen', urlopen) + + +@pytest.mark.parametrize('body', [ + b'', b'{not json', b'\xff\xfe', b'[]', b'{"errors": "check-and-set"}', +], ids=['empty', 'malformed', 'undecodable', 'array', 'errors-not-a-list']) +def test_write_400_without_readable_errors_is_a_refusal(store, openbao, monkeypatch, body): + """現状固定: 400 の本文から errors を読めなければ版の不一致ではなく拒否。""" + openbao.put(TEAM_GLOBAL_PATH, {'A': '1'}) + store.load(GLOBAL) + _write_fails_with(monkeypatch, 400, body) + + with pytest.raises(SecretRefusedError) as exc: + store.save(GLOBAL, {'A': '2'}) + + assert not isinstance(exc.value, (SecretAuthError, SecretConflictError)) + + +def test_write_400_errors_are_read_as_strings(store, openbao, monkeypatch): + """現状固定: errors の要素は文字列にしてから版の不一致の語を探す。""" + openbao.put(TEAM_GLOBAL_PATH, {'A': '1'}) + store.load(GLOBAL) + _write_fails_with(monkeypatch, 400, b'{"errors": [1, "check-and-set mismatch"]}') + + with pytest.raises(SecretConflictError): + store.save(GLOBAL, {'A': '2'}) + + def test_an_expiring_token_is_renewed_before_the_request(store, openbao, monkeypatch): import time From bca2cc638b85efbf5441fa1a988c4edba492437d Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 17 Oct 2026 16:42:29 +0900 Subject: [PATCH 12/12] =?UTF-8?q?Fix:=20OpenBao=20=E3=81=AE=20TLS=20?= =?UTF-8?q?=E3=82=B3=E3=83=B3=E3=83=86=E3=82=AD=E3=82=B9=E3=83=88=E3=81=B8?= =?UTF-8?q?=E6=97=A2=E5=AE=9A=E3=81=AE=20verify=5Fflags=20=E3=82=92?= =?UTF-8?q?=E5=86=99=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit truststore.SSLContext(PROTOCOL_TLS_CLIENT) は、Python 3.13 の ssl.create_default_context() が立てる VERIFY_X509_STRICT と VERIFY_X509_PARTIAL_CHAIN を立てない。今までの urlopen が拒んだ不適合な 証明書チェーンを受け入れないよう、その Python の既定の verify_flags を写す。 3.10・3.13 で truststore の SSLContext が verify_flags の設定を受けることを 確かめ、版によらず写されることを見る回帰テストを足した。 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NjbAm85zNM1QmoDYNiX5Dy --- lib/devbase/env/openbao.py | 8 +++++++- tests/env/test_openbao.py | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/lib/devbase/env/openbao.py b/lib/devbase/env/openbao.py index 67ecd2dd..61a99226 100644 --- a/lib/devbase/env/openbao.py +++ b/lib/devbase/env/openbao.py @@ -74,6 +74,10 @@ def _tls_context() -> Optional[ssl.SSLContext]: """OpenBao への HTTP に渡す SSL コンテキスト。OS の信頼ストアで検証する。 ``truststore.SSLContext`` は検証を緩めない (``CERT_REQUIRED``・ホスト名の照合)。 + ただし ``verify_flags`` は ``ssl.create_default_context()`` と揃わない。Python 3.13 の + 既定は ``VERIFY_X509_STRICT`` と ``VERIFY_X509_PARTIAL_CHAIN`` を立てるが、 + ``truststore.SSLContext`` は立てない。今までの ``urlopen`` が拒んだ不適合な証明書を + 受け入れないよう、その Python の既定の ``verify_flags`` を写す。 ``truststore.inject_into_ssl()`` は使わない。プロセス全体の ``ssl`` を差し替え、 boto3 など他の通信の検証まで変えてしまうため。``truststore`` を import できない ときは ``None`` を返し、``urlopen`` は今までどおり既定のコンテキストで検証する。 @@ -83,7 +87,9 @@ def _tls_context() -> Optional[ssl.SSLContext]: except ImportError: logger.debug("truststore を読み込めないため、既定の SSL コンテキストで OpenBao へ接続します") return None - return truststore.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + context = truststore.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + context.verify_flags = ssl.create_default_context().verify_flags + return context class SecretUnreachableError(SecretStoreError): diff --git a/tests/env/test_openbao.py b/tests/env/test_openbao.py index 7e1ef403..ea575e7a 100644 --- a/tests/env/test_openbao.py +++ b/tests/env/test_openbao.py @@ -797,6 +797,38 @@ def test_every_openbao_route_uses_the_truststore_context(store, openbao, recorde import ssl assert context.verify_mode == ssl.CERT_REQUIRED assert context.check_hostname is True + assert context.verify_flags == ssl.create_default_context().verify_flags + + +def test_truststore_context_copies_the_default_verify_flags(monkeypatch): + """既定のコンテキストが立てる検証のフラグ (3.13 の ``VERIFY_X509_STRICT`` など) を落とさない + + Python の版によらず確かめるため、既定のコンテキストへ ``VERIFY_X509_STRICT`` と + ``VERIFY_X509_PARTIAL_CHAIN`` を足した形にして、写されることを見る。 + """ + import ssl + + import truststore + from devbase.env import openbao as openbao_module + + strict = ssl.VERIFY_X509_STRICT | ssl.VERIFY_X509_PARTIAL_CHAIN + real = ssl.create_default_context + + def create_default_context(*args, **kwargs): + context = real(*args, **kwargs) + context.verify_flags |= strict + return context + + monkeypatch.setattr(ssl, 'create_default_context', create_default_context) + openbao_module._tls_context.cache_clear() + try: + context = openbao_module._tls_context() + finally: + openbao_module._tls_context.cache_clear() + + assert isinstance(context, truststore.SSLContext) + assert context.verify_flags & strict == strict + assert context.verify_flags == create_default_context().verify_flags def test_truststore_is_not_injected_into_the_process(store, openbao, recorded_urlopen):