NK-WP-0048: run keycape check-config before any registry patch
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s

keycape-client.py --check-config runs KeyCape's startup checks (key-cape
c528daa) on the live and proposed registry via stdin; apply refuses a
registry KeyCape would refuse at start. Type 1.1 patch names the option.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 736651@bnt-lap001
Assistant-Session: f6189624-fd64-4109-b53e-f8a59ca8a2d8
This commit is contained in:
tegwick 2026-10-10 15:28:05 +02:00
parent 1c2d0c2cf6
commit 35880bbce4
5 changed files with 97 additions and 10 deletions

View file

@ -129,6 +129,9 @@ uses them.
## History
- **1.1 patch** (2026-10-10): `register-client` runs `keycape check-config`
(key-cape c528daa) through `keycape-client.py --check-config` on the live and
the proposed registry; apply refuses a registry KeyCape would refuse at start.
- **1.1** (2026-10-10, minor): verification `consumer-enforces-groups`, and
hand-over requires the consumer to enforce `access_groups`, because KeyCape has
no per-client group gate (key-cape type review R2, key-cape 6038483). No new

View file

@ -219,7 +219,7 @@ spec:
closed if the binding is missing.
- id: register-client
owner: net-kingdom
entry_point: "sso-mfa/k8s/keycape/keycape-client.py apply --client-file <client-from-case> --case-acceptance <record> (net-kingdom.keycape-client-registration)"
entry_point: "sso-mfa/k8s/keycape/keycape-client.py apply --client-file <client-from-case> --case-acceptance <record> --check-config <keycape binary> (net-kingdom.keycape-client-registration)"
auth: "operator write access to the live KeyCape client registry in namespace sso"
lane: key-cape-oidc-login
warden_executes: false
@ -234,8 +234,10 @@ spec:
at startup and keeps login, authorization and handoff state in memory, so
the restart drops in-flight logins and sessions for every client; runs go
in a quiet window (key-cape type review R4). The rendered registry must
pass KeyCape's own client validation before the restart: a refused entry
stops the single replica and every client loses sign-in (R1).
pass KeyCape's own startup checks (keycape check-config, key-cape
c528daa, which keycape-client.py runs before it patches) before the
restart: a refused entry stops the single replica and every client
loses sign-in (R1).
- id: hand-over
owner: "{consumer_owner_repo}"
entry_point: "configure {consuming_service} with issuer {issuer}, client {client_id} and custody bao:platform/workloads/keycape/{client_id}, and make it require one of {access_groups} in the groups claim before sign-in completes"

View file

@ -16,6 +16,9 @@ secrets-engine-openbao-client-rollout) and keeps their safeguards:
- compare-and-swap on the Secret's uid and resourceVersion, then read-back
- confidential clients: the KeyCape Deployment must already bind the client's
secret env var (delivered from custody), or the next pod start fails closed
- KeyCape's own startup checks (keycape check-config, key-cape c528daa) on the
live and the proposed registry, fed on stdin and never written to disk; apply
refuses a registry that would stop the single replica at its next start
- no Secret value, parser message or Kubernetes error text leaves the process
Modes: inspect (default), dry-run (server-side), apply. apply requires the
@ -25,7 +28,7 @@ was accepted under an acknowledged type.
keycape-client.py client-from-case <case.yaml>
keycape-client.py inspect --client-file client.yaml [--operation remove]
keycape-client.py apply --client-file client.yaml --case-acceptance <record> \\
--expected-uid <uid> --expected-resource-version <rv>
--check-config <keycape binary> --expected-uid <uid> --expected-resource-version <rv>
"""
from __future__ import annotations
@ -35,6 +38,7 @@ import copy
import importlib.util
import json
import re
import subprocess
import sys
from pathlib import Path
@ -194,6 +198,25 @@ def deployment_bound(deployment, client):
return bool(matches)
def check_config(binary, config_text, env_names, policy_store):
"""KeyCape's startup checks on one registry: names and messages only, no values."""
command = [binary, 'check-config', '--config', '/dev/stdin', '--env-names', ','.join(env_names)]
if policy_store:
command.append('--policy-store')
result = subprocess.run(command, input=config_text.encode(), capture_output=True, timeout=60)
report = json.loads(result.stdout)
require(isinstance(report, dict) and isinstance(report.get('valid'), bool)
and report['valid'] == (result.returncode == 0), 'check_config_output_shape')
return {'valid': report['valid'], 'clients': report.get('clients'),
'errors': [str(e) for e in report.get('errors') or []]}
def deployment_env_names(deployment):
containers = deployment['spec']['template']['spec']['containers']
container = next(c for c in containers if c['name'] == 'keycape')
return sorted(e['name'] for e in container.get('env', []))
def run(args, client):
kubectl = lane.kubectl
cluster = kubectl(['get', 'namespace', 'kube-system', '-o', 'json'])
@ -202,19 +225,27 @@ def run(args, client):
require(not before['metadata'].get('ownerReferences'), 'controller_owned_secret')
metadata = pin.safe_metadata(before)
encoded, changed = (replacement if args.operation == 'add' else removal)(before, client)
bound = None
if 'secretRef' in client:
bound = deployment = None
if 'secretRef' in client or args.check_config:
deployment = kubectl(['-n', 'sso', 'get', 'deployment', 'keycape', '-o', 'json'])
bound = deployment_bound(deployment, client)
checks = None
if args.check_config:
names = deployment_env_names(deployment)
policy_store = 'KEYCAPE_POLICY_PATH' in names
checks = {stage: check_config(args.check_config, base64.b64decode(config).decode(), names, policy_store)
for stage, config in (('live', before['data']['config.yaml']), ('proposed', encoded))}
receipt = {'schema': 'netkingdom.keycape-client.v1', 'target': 'sso/keycape-config',
'before': metadata, 'operation': args.operation, 'client_id': client['clientId'],
'client_type': client['clientType'], 'change_needed': changed, 'mode': args.mode,
'deployment_env_bound': bound, 'values_emitted': False, 'changed': False}
'deployment_env_bound': bound, 'check_config': checks, 'values_emitted': False, 'changed': False}
if args.mode == 'inspect':
return receipt
if args.mode == 'apply':
require(bool(args.case_acceptance), 'case_acceptance_record_required')
receipt['case_acceptance'] = args.case_acceptance
require(checks is not None, 'keycape_check_config_required')
require(checks['proposed']['valid'], 'proposed_registry_fails_keycape_check_config')
if args.operation == 'add' and bound is not None:
require(bound, 'deployment_env_binding_required_first')
require(metadata == {'uid': args.expected_uid, 'resource_version': args.expected_resource_version},
@ -251,6 +282,7 @@ def main(argv=None):
parser.add_argument('--expected-uid')
parser.add_argument('--expected-resource-version')
parser.add_argument('--case-acceptance', help='the case acceptance record; required for apply')
parser.add_argument('--check-config', help='a keycape binary with check-config (key-cape c528daa or later); required for apply')
args = parser.parse_args(argv)
try:
if args.mode == 'client-from-case':

View file

@ -156,17 +156,53 @@ class FakeKube:
def args(mode, **kw):
base = dict(mode=mode, operation='add', expected_uid=UID, expected_resource_version='7', case_acceptance=None)
base = dict(mode=mode, operation='add', expected_uid=UID, expected_resource_version='7', case_acceptance=None,
check_config='keycape')
base.update(kw)
return argparse.Namespace(**base)
class FakeCheck:
"""Stands in for keycape check-config; refuses registries naming a client in `refuse`."""
def __init__(self, refuse=()):
self.refuse = refuse
self.calls = []
def __call__(self, binary, config_text, env_names, policy_store):
self.calls.append((binary, env_names, policy_store))
ids = [c['clientId'] for c in yaml.safe_load(config_text)['clients']]
errors = [f'client {i!r}: refused' for i in ids if i in self.refuse]
return {'valid': not errors, 'clients': len(ids), 'errors': errors}
class RunTests(unittest.TestCase):
def setUp(self):
self.original = kc.lane.kubectl
self.original = kc.lane.kubectl, kc.check_config
kc.check_config = FakeCheck()
def tearDown(self):
kc.lane.kubectl = self.original
kc.lane.kubectl, kc.check_config = self.original
def test_apply_needs_keycape_check_config(self):
kc.lane.kubectl = FakeKube(fixture()[0])
with self.assertRaises(kc.Refused):
kc.run(args('apply', case_acceptance='decision:x', check_config=None), FORGEJO)
def test_apply_refuses_a_registry_keycape_would_refuse(self):
fake = FakeKube(fixture()[0])
kc.lane.kubectl = fake
kc.check_config = FakeCheck(refuse=('forgejo',))
with self.assertRaises(kc.Refused):
kc.run(args('apply', case_acceptance='decision:x'), FORGEJO)
self.assertEqual([], fake.patches)
def test_inspect_reports_live_and_proposed_checks_with_deployment_env(self):
kc.lane.kubectl = FakeKube(fixture()[0])
kc.check_config = FakeCheck(refuse=('forgejo',))
receipt = kc.run(args('inspect'), FORGEJO)
self.assertTrue(receipt['check_config']['live']['valid'])
self.assertFalse(receipt['check_config']['proposed']['valid'])
self.assertEqual([kc.env_name('forgejo')], kc.check_config.calls[0][1])
def test_inspect_changes_nothing_and_emits_no_values(self):
fake = FakeKube(fixture()[0])

View file

@ -416,6 +416,20 @@ acknowledgment intake keeps `@1.0` as the record of what was acknowledged.
ops-mason's build and ops-warden's case-ref digest predate this and need a
re-render.
2026-10-10: ops-mason re-rendered the Forgejo case from `@1.1` (message
`22c6bba6`; plan digest `af1f4f81…`, no collisions) and rebuilt the bundle. The
rendered OpenBao objects are identical to 1.0, so the already applied
CCR-2026-0041 stands; 1.1 changes only consumer-side steps. key-cape shipped
`keycape check-config` (c528daa, message `a7b0aeff`): KeyCape's startup checks
without secret values, including the new duplicate-clientId rule.
`keycape-client.py --check-config <binary>` now runs it on the live and the
proposed registry, fed on stdin, and apply refuses a proposed registry that
fails; register-client's entry point names the option (1.1 patch). The deployed
image predates c528daa, so the binary is built from key-cape source for now.
Before the next KeyCape rollout (Forgejo register-client or the CCR-2026-0018
window), the attended operator runs `inspect --check-config` so the live
registry is checked too.
```task
id: NK-WP-0048-T07
status: wait