From 8d89611b222ed2a45356166f5976abb9f234838e Mon Sep 17 00:00:00 2001 From: tegwick Date: Mon, 27 Jul 2026 02:06:59 +0200 Subject: [PATCH] Plan Qonto Kubernetes credential lane --- plans/rapp-qonto-openbao-kubernetes-lane.md | 113 ++++++++++++++++++++ src/ops_mason/executor.py | 52 +++++++++ tests/test_executor.py | 58 ++++++++++ 3 files changed, 223 insertions(+) create mode 100644 plans/rapp-qonto-openbao-kubernetes-lane.md diff --git a/plans/rapp-qonto-openbao-kubernetes-lane.md b/plans/rapp-qonto-openbao-kubernetes-lane.md new file mode 100644 index 0000000..308b5f4 --- /dev/null +++ b/plans/rapp-qonto-openbao-kubernetes-lane.md @@ -0,0 +1,113 @@ +--- +id: rapp-qonto-openbao-kubernetes-lane +demand_source: reef-railiance/workplans/REEF-RAILIANCE-WP-0003-T01 +consumer_repo: rapp-qonto +credential_type: openbao-kubernetes-kv +status: reviewed +approved_by: null +approved_at: null +created: "2026-07-27" +updated: "2026-07-27" +--- + +# Construction plan: rapp-qonto OpenBao Kubernetes lane + +## 1. Demand + +`rapp-qonto` needs non-interactive, read-only delivery of the existing Binky +Qonto API credential from `tenants/binky/qonto-api` into its dedicated +Kubernetes namespace. External Secrets Operator should authenticate to OpenBao +with Kubernetes workload identity; neither the application nor a +ClusterSecretStore should hold a long-lived OpenBao token. + +## 2. Existing-structure survey + +Live OpenBao state was checked on 2026-07-27: + +- auth mounts include `kubernetes/`; its configured API endpoint is the + cluster-local `https://10.43.0.1:443`; +- existing Kubernetes roles are `external-secrets-activity-core`, + `external-secrets-issue-core`, and `external-secrets-reuse-surface`; +- policy `workload-kv-read-binky-qonto-api` already exists and is scoped to + the existing tenant credential; +- the `tenants/` KV v2 mount and `tenants/binky/qonto-api` value already + exist; this plan does not create or read the value; +- existing cluster stores use an interim static-token pattern, despite the + Kubernetes roles existing; +- `CCR-2026-0009` proposed a duplicate + `workload-kv-read-qonto-assistant` policy and the transitional + `qonto-assistant` namespace/name. + +The existing read policy satisfies the requested data scope exactly. Reuse it; +do not create the duplicate policy from CCR-2026-0009. A new authentication +role is still required because no existing role should gain Qonto access and +no existing Qonto lane is workload-authenticated. + +## 3. Proposed changes + +| # | Action | Object | Reuse-vs-new rationale | +|---|---|---|---| +| 1 | reuse | policy `workload-kv-read-binky-qonto-api` | Already grants the exact required read scope; a second equivalent policy adds drift without isolation | +| 2 | create | Kubernetes auth role `external-secrets-rapp-qonto` | New identity binding only; bound to service account `external-secrets` in namespace `external-secrets`, policy #1, token TTL 15 minutes | +| 3 | create | `ClusterSecretStore/openbao-rapp-qonto` | Uses Vault Kubernetes auth role #2 and the `tenants` KV v2 mount; namespace condition permits only `rapp-qonto` | +| 4 | modify | `rapp-qonto` ExternalSecret | Map remote fields `API_USER` -> `QONTO_LOGIN` and `API_KEY` -> `QONTO_SECRET_KEY`; no values enter Git or logs | +| 5 | retire | proposed `openbao-qonto-assistant` static-token store | Superseded before deployment; avoids a long-lived OpenBao token in a Kubernetes Secret | +| 6 | propose | ops-warden catalog entry `rapp-qonto-workload-kv` | Pointer to the built Kubernetes-auth lane, `warden_executes: false`, initially `draft` | + +No underlying Qonto secret is created, changed, printed, or rotated. + +## 4. Review notes + +- **Naming:** steady-state `rapp-qonto` names replace the migration-only + `qonto-assistant` names. +- **TTL/scoping:** 15-minute tokens match existing External Secrets roles. + The role binds only the ESO service account; the ClusterSecretStore + condition restricts consumers to one namespace. +- **Redundancy:** reuses the exact existing policy. This removes the duplicate + policy proposed by CCR-2026-0009. +- **Compaction:** the draft static-token Qonto store is retired before it is + deployed. Existing unrelated stores are untouched. +- **Consumer usability:** External Secrets performs authentication and + delivery. The application consumes only a namespaced Kubernetes Secret. +- **Posture:** the lane accesses a high-risk bank credential, but it is + read-only and the runtime exposes no financial write operation. Production + still requires egress, audit, revocation, and failure-domain gates. +- **Build executor extension:** ops-mason currently builds AppRole lanes only. + Phase 4 must add a narrowly tested Kubernetes-role operation with the same + approval refusal and metadata-only audit guarantees before applying this + plan. + +## 5. Executive summary + +**One-line ask:** allow External Secrets on railiance01 to deliver the existing +Binky Qonto read credential to `rapp-qonto` without storing a long-lived +OpenBao token in Kubernetes. + +**Who/what gets access:** a new OpenBao Kubernetes role named +`external-secrets-rapp-qonto`, usable only by the existing +`external-secrets` service account in the `external-secrets` namespace. No +application pod, human role, or unrelated service account receives OpenBao +login rights. + +**To what:** read-only access through the existing +`workload-kv-read-binky-qonto-api` policy to exactly +`tenants/binky/qonto-api`. Store use is limited to ExternalSecret resources in +the `rapp-qonto` namespace. + +**For how long:** each Kubernetes-auth login receives a 15-minute OpenBao +token. There is no delivered AppRole secret ID and no long-lived OpenBao token +stored in Kubernetes. The Kubernetes service-account identity remains valid +while the ESO deployment and role binding exist. + +**Blast radius if the identity is abused:** an attacker able to act as the ESO +service account and use this role could read the Binky Qonto API credential. +The OpenBao policy cannot read any sibling tenant secret. The downstream Qonto +key remains high-risk and must be rotated if exposed. + +**Cost to reverse:** delete one Kubernetes auth role, one ClusterSecretStore, +and the `rapp-qonto` ExternalSecret/derived Secret. The existing human Qonto +lane and underlying credential remain intact; no unrelated consumer changes. + +**Decision needed:** approve as proposed, reject, or send back to phase 1 with +changes. Approval authorizes the structural access lane only, never disclosure +or modification of the Qonto credential value. diff --git a/src/ops_mason/executor.py b/src/ops_mason/executor.py index bab06d6..622ba1e 100644 --- a/src/ops_mason/executor.py +++ b/src/ops_mason/executor.py @@ -55,6 +55,17 @@ class AppRoleKVSpec: audit_log_path: Path | None = None +@dataclass +class KubernetesKVSpec: + policy_name: str + role_name: str + service_account_names: tuple[str, ...] + service_account_namespaces: tuple[str, ...] + token_ttl: str = "15m" + audit_log_path: Path | None = None + bao_bin: str = "bao" + + def _run(bao_bin: str, args: list[str], input_text: str | None = None) -> str: proc = subprocess.run( [bao_bin, *args], @@ -151,3 +162,44 @@ def build_approle_kv_lane(plan: ConstructionPlan, spec: AppRoleKVSpec) -> dict[s log_path=spec.audit_log_path, ) return objects + + +def build_kubernetes_kv_lane( + plan: ConstructionPlan, spec: KubernetesKVSpec +) -> dict[str, str]: + """Create a policy-bound Kubernetes auth role without handling secret values.""" + if not plan.is_approved(): + raise BuildRefused( + f"plan {plan.id!r} is not approved " + f"(status={plan.status!r}, approved_by={plan.approved_by!r}, " + f"approved_at={plan.approved_at!r}) — refusing to build" + ) + if not spec.service_account_names or not spec.service_account_namespaces: + raise BuildRefused("Kubernetes role requires explicit service account bindings") + + _run( + spec.bao_bin, + [ + "write", + f"auth/kubernetes/role/{spec.role_name}", + f"bound_service_account_names={','.join(spec.service_account_names)}", + f"bound_service_account_namespaces={','.join(spec.service_account_namespaces)}", + f"policies={spec.policy_name}", + f"ttl={spec.token_ttl}", + ], + ) + objects = { + "policy_name": spec.policy_name, + "kubernetes_role_name": spec.role_name, + "service_account_names": ",".join(spec.service_account_names), + "service_account_namespaces": ",".join(spec.service_account_namespaces), + } + record_build( + plan_id=plan.id, + plan_path=str(plan.path), + approved_by=plan.approved_by or "", + approved_at=plan.approved_at or "", + objects=objects, + log_path=spec.audit_log_path, + ) + return objects diff --git a/tests/test_executor.py b/tests/test_executor.py index cb3d384..e4c5fea 100644 --- a/tests/test_executor.py +++ b/tests/test_executor.py @@ -7,8 +7,10 @@ from ops_mason.executor import ( AppRoleKVSpec, BuildError, BuildRefused, + KubernetesKVSpec, _policy_hcl, build_approle_kv_lane, + build_kubernetes_kv_lane, ) @@ -146,3 +148,59 @@ def test_bao_failure_raises_build_error(tmp_path) -> None: with patch("ops_mason.executor.subprocess.run", return_value=fake_result): with pytest.raises(BuildError, match="permission denied"): build_approle_kv_lane(plan, spec) + + +def _kubernetes_spec(tmp_path) -> KubernetesKVSpec: + return KubernetesKVSpec( + policy_name="workload-kv-read-binky-qonto-api", + role_name="external-secrets-rapp-qonto", + service_account_names=("external-secrets",), + service_account_namespaces=("external-secrets",), + audit_log_path=tmp_path / "audit.jsonl", + ) + + +def test_kubernetes_lane_refusal_never_calls_bao(tmp_path) -> None: + plan = _plan(tmp_path, status="reviewed", approved_by=None, approved_at=None) + with patch("ops_mason.executor.subprocess.run") as run: + with pytest.raises(BuildRefused): + build_kubernetes_kv_lane(plan, _kubernetes_spec(tmp_path)) + run.assert_not_called() + + +def test_kubernetes_lane_builds_exact_service_account_binding(tmp_path) -> None: + plan = _plan(tmp_path) + spec = _kubernetes_spec(tmp_path) + result = MagicMock(returncode=0, stderr="", stdout="") + with ( + patch("ops_mason.executor.subprocess.run", return_value=result) as run, + patch("ops_mason.executor.record_build") as audit, + ): + objects = build_kubernetes_kv_lane(plan, spec) + + command = run.call_args.args[0] + assert command == [ + "bao", + "write", + "auth/kubernetes/role/external-secrets-rapp-qonto", + "bound_service_account_names=external-secrets", + "bound_service_account_namespaces=external-secrets", + "policies=workload-kv-read-binky-qonto-api", + "ttl=15m", + ] + assert objects["kubernetes_role_name"] == "external-secrets-rapp-qonto" + audit.assert_called_once() + + +def test_kubernetes_lane_requires_nonempty_bindings(tmp_path) -> None: + plan = _plan(tmp_path) + spec = KubernetesKVSpec( + policy_name="policy", + role_name="role", + service_account_names=(), + service_account_namespaces=("external-secrets",), + ) + with patch("ops_mason.executor.subprocess.run") as run: + with pytest.raises(BuildRefused, match="explicit service account"): + build_kubernetes_kv_lane(plan, spec) + run.assert_not_called()