Plan Qonto Kubernetes credential lane
This commit is contained in:
parent
3125e94219
commit
8d89611b22
3 changed files with 223 additions and 0 deletions
113
plans/rapp-qonto-openbao-kubernetes-lane.md
Normal file
113
plans/rapp-qonto-openbao-kubernetes-lane.md
Normal 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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue