NK-WP-0048: run keycape check-config before any registry patch
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:
parent
1c2d0c2cf6
commit
35880bbce4
5 changed files with 97 additions and 10 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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':
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue