Plan Qonto Kubernetes credential lane

This commit is contained in:
tegwick 2026-07-27 02:06:59 +02:00
parent 3125e94219
commit 8d89611b22
3 changed files with 223 additions and 0 deletions

View file

@ -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.

View file

@ -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

View file

@ -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()