From 6c7c37aa704e7cdeb716fc3fbbbfcab76481897c Mon Sep 17 00:00:00 2001 From: tegwick Date: Sat, 31 Oct 2026 10:31:30 +0100 Subject: [PATCH] NK-WP-0056-T04: svc-keycape bind lane (source only, CCR-2026-0054) KeyCape takes KEYCAPE_LLDAP_BIND_PW (key-cape KEY-WP-0046). ESO creates the dedicated Secret keycape-lldap-bind. bind-lane setup reads the current bindPW from keycape-config and requires the svc-keycape bindDN; verify compares the delivered Secret, and the config value while it is present. directory-bind-rollout gains clear-password, which runs only with the service bind and the delivered env (switch restores). deployment.yaml pins image main-e2b5fa1 and adds the env. Nothing is applied. Co-Authored-By: Claude Opus 5.5 Assistant: claude-code Assistant-Model: opus Assistant-Process: 896183@bnt-lap001 Assistant-Session: 3a18c01a-3205-4d02-94db-9efff25649a2 --- sso-mfa/k8s/keycape/bind-eso.yaml | 57 ++++++++++++++++ sso-mfa/k8s/keycape/deployment.yaml | 17 +++-- sso-mfa/k8s/keycape/directory-bind-rollout.py | 26 +++++++- tests/test_keycape_directory_bind_rollout.py | 50 ++++++++++++++ tests/test_service_bind_lane.py | 65 +++++++++++++++++-- tools/service-binds/bind-lane.py | 45 ++++++++++++- workplans/NK-WP-0056-bounded-executor-path.md | 11 ++++ 7 files changed, 255 insertions(+), 16 deletions(-) create mode 100644 sso-mfa/k8s/keycape/bind-eso.yaml diff --git a/sso-mfa/k8s/keycape/bind-eso.yaml b/sso-mfa/k8s/keycape/bind-eso.yaml new file mode 100644 index 0000000..b145ad5 --- /dev/null +++ b/sso-mfa/k8s/keycape/bind-eso.yaml @@ -0,0 +1,57 @@ +# NK-WP-0056-T04, railiance-platform CCR-2026-0054. +# Creates the dedicated Secret sso/keycape-lldap-bind (key password) from platform custody. +# KeyCape (image with key-cape KEY-WP-0046) reads it as KEYCAPE_LLDAP_BIND_PW; until +# bindPW is cleared from keycape-config both carry the same value, and KeyCape refuses +# to start if they differ. ESO is the only writer of this Secret (creationPolicy Owner); +# Retain leaves it in place if this ExternalSecret is removed. +# Apply only after `tools/service-binds/bind-lane.py setup svc-keycape` reports equal custody. +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: net-kingdom-svc-keycape-bind-eso + namespace: sso +automountServiceAccountToken: false +--- +apiVersion: external-secrets.io/v1 +kind: SecretStore +metadata: + name: net-kingdom-svc-keycape-bind + namespace: sso +spec: + provider: + vault: + server: http://openbao.openbao.svc:8200 + path: platform + version: v2 + auth: + kubernetes: + mountPath: kubernetes + role: net-kingdom-svc-keycape-bind-eso + serviceAccountRef: + name: net-kingdom-svc-keycape-bind-eso + audiences: [openbao] +--- +apiVersion: external-secrets.io/v1 +kind: ExternalSecret +metadata: + name: keycape-lldap-bind + namespace: sso +spec: + refreshInterval: 1h + secretStoreRef: + kind: SecretStore + name: net-kingdom-svc-keycape-bind + target: + # Explicit target metadata stops ESO copying this object's kubectl + # last-applied annotation onto the Secret (refused by reject-secret-last-applied). + template: + metadata: {} + name: keycape-lldap-bind + creationPolicy: Owner + deletionPolicy: Retain + data: + - secretKey: password + remoteRef: + key: workloads/net-kingdom/directory-binds/svc-keycape + property: LDAP_BIND_PASSWORD diff --git a/sso-mfa/k8s/keycape/deployment.yaml b/sso-mfa/k8s/keycape/deployment.yaml index f79f59e..d4ca75c 100644 --- a/sso-mfa/k8s/keycape/deployment.yaml +++ b/sso-mfa/k8s/keycape/deployment.yaml @@ -51,10 +51,12 @@ spec: containers: - name: keycape # Image published to the self-hosted Forgejo OCI registry (KEY-WP-0002). - # key-cape@1164f65: explicit railiance-admins group maps to - # railiance-admin; retains the 8be8065 split-horizon discovery fix. - # Rollback: sha256:61b5f222a35c6ee1dc0273ea581bdad3d8d98e58b2c37327ac169639252e8ef5 - image: forgejo.coulomb.social/coulomb/key-cape@sha256:cdec9dd7c515f7e0a6169fc3fa560ee2cc47bf40d899755dec808c362f5ce010 + # key-cape@e2b5fa1 (main-e2b5fa1, KEY-WP-0046): reads the directory bind + # password from KEYCAPE_LLDAP_BIND_PW; also carries the opt-in token status + # (67a0869..e2c5a94, inactive while tokenStatus is unset) and identity-facts. + # Rollback: sha256:cdec9dd7c515f7e0a6169fc3fa560ee2cc47bf40d899755dec808c362f5ce010 + # (af3b1b0, sign-in diagnosis page) together with removing KEYCAPE_LLDAP_BIND_PW. + image: forgejo.coulomb.social/coulomb/key-cape@sha256:9e082dca78f65a4af3e92d3de3705b99e49ca9b298d9562ad0d37511c4306f8d imagePullPolicy: IfNotPresent ports: @@ -77,6 +79,13 @@ spec: value: https://auth.coulomb.social/logout - name: KEYCAPE_CONFIG value: /etc/keycape/config.yaml + # svc-keycape directory bind from OpenBao via ESO (NK-WP-0056-T04, CCR-2026-0054). + # KeyCape refuses to start if this differs from lldap.bindPW in keycape-config. + - name: KEYCAPE_LLDAP_BIND_PW + valueFrom: + secretKeyRef: + name: keycape-lldap-bind + key: password - name: KEYCAPE_RAPP_QONTO_CLIENT_SECRET valueFrom: secretKeyRef: diff --git a/sso-mfa/k8s/keycape/directory-bind-rollout.py b/sso-mfa/k8s/keycape/directory-bind-rollout.py index 1baad84..6b3b76a 100644 --- a/sso-mfa/k8s/keycape/directory-bind-rollout.py +++ b/sso-mfa/k8s/keycape/directory-bind-rollout.py @@ -4,7 +4,10 @@ Edits exactly two scalars in `lldap` of `config.yaml` in sso/keycape-config: `bindDN` and `bindPW`. `--operation switch` sets `uid=svc-keycape,...` and the password from the private custody copy (`lldap/secrets.env`, key LLDAP_SVC_KEYCAPE_PASS); `--operation revert` sets `uid=admin,...` back and -takes the admin password at a hidden prompt. The signing key, every client and every other config byte +takes the admin password at a hidden prompt. `--operation clear-password` (NK-WP-0056-T04) empties +`bindPW` while the bind stays `svc-keycape`, and only when the keycape Deployment already takes +KEYCAPE_LLDAP_BIND_PW from the ESO Secret keycape-lldap-bind; `switch` puts the custody password back. +The signing key, every client and every other config byte are preserved and checked by re-parsing. Modes default to non-mutating, as in openbao-signin-diagnosis-rollout.py: `inspect` reads and plans, @@ -46,6 +49,18 @@ custody = module( SERVICE_DN = "uid=svc-keycape,ou=people,dc=netkingdom,dc=local" HUMAN_DN = "uid=admin,ou=people,dc=netkingdom,dc=local" +BIND_ENV = { + "name": "KEYCAPE_LLDAP_BIND_PW", + "valueFrom": {"secretKeyRef": {"name": "keycape-lldap-bind", "key": "password"}}, +} + + +def env_delivered(deployment): + """True only if the keycape container takes the bind password from the ESO Secret.""" + containers = deployment["spec"]["template"]["spec"]["containers"] + keycape = [c for c in containers if c.get("name") == "keycape"] + require(len(keycape) == 1, "keycape_container_required") + return BIND_ENV in keycape[0].get("env", []) def service_password(root): @@ -121,6 +136,11 @@ def run(args, kubectl=None, prompt=getpass.getpass): metadata = pin.safe_metadata(before) if args.operation == "switch": dn, password = SERVICE_DN, service_password(args.custody_root) + elif args.operation == "clear-password": + require(_lldap(before)[1]["lldap"]["bindDN"] == SERVICE_DN, "clear_requires_service_bind") + deployment = kubectl(["-n", "sso", "get", "deployment", "keycape", "-o", "json"]) + require(env_delivered(deployment), "bind_env_not_delivered") + dn, password = SERVICE_DN, "" else: dn, password = ( HUMAN_DN, @@ -130,7 +150,7 @@ def run(args, kubectl=None, prompt=getpass.getpass): encoded, changed, prior = replace(before, dn, password) receipt = { "schema": "netkingdom.keycape-directory-bind.v1", - "task": "NK-WP-0054-T05", + "task": "NK-WP-0056-T04" if args.operation == "clear-password" else "NK-WP-0054-T05", "target": "sso/keycape-config", "before": metadata, "operation": args.operation, @@ -201,7 +221,7 @@ def main(argv=None, kubectl=None, prompt=getpass.getpass): parser.add_argument( "mode", nargs="?", default="inspect", choices=["inspect", "dry-run", "apply"] ) - parser.add_argument("--operation", default="switch", choices=["switch", "revert"]) + parser.add_argument("--operation", default="switch", choices=["switch", "revert", "clear-password"]) parser.add_argument( "--custody-root", help="private custody copy; required for switch" ) diff --git a/tests/test_keycape_directory_bind_rollout.py b/tests/test_keycape_directory_bind_rollout.py index 7ecdb99..3f365cb 100644 --- a/tests/test_keycape_directory_bind_rollout.py +++ b/tests/test_keycape_directory_bind_rollout.py @@ -164,3 +164,53 @@ def test_main_contains_failures_without_values(capsys): assert m.main(["inspect", "--custody-root", "x"], kubectl=broken) == 1 out = capsys.readouterr().out assert "contained_operation_failed" in out and "old-shared-password" not in out + + +def deployment(env): + return {"spec": {"template": {"spec": {"containers": [{"name": "keycape", "env": env}]}}}} + + +class ClusterWithDeployment(Cluster): + def __init__(self, obj, env): + super().__init__(obj) + self.deployment = deployment(env) + + def __call__(self, args, payload=None): + if "deployment" in args: + self.calls.append((args, payload)) + return copy.deepcopy(self.deployment) + return super().__call__(args, payload) + + +def test_clear_password_empties_only_bindpw_once_the_env_is_delivered(): + cluster = ClusterWithDeployment(secret(), [m.BIND_ENV]) + m.run(args("apply", attended=True), cluster) # switch to svc-keycape first + receipt = m.run(args("apply", "clear-password", attended=True, version="42"), cluster) + assert receipt["changed"] and receipt["task"] == "NK-WP-0056-T04" + after = yaml.safe_load(config_of(cluster.obj)) + assert after["lldap"] == dict(yaml.safe_load(CONFIG)["lldap"], bindDN=m.SERVICE_DN, bindPW="") + assert cluster.obj["data"]["other"] == "b3RoZXI=" + assert SERVICE_PASSWORD not in json.dumps(receipt) + # switch is the rollback: it puts the custody password back + restored = m.run(args("apply", attended=True, version="42"), cluster) + assert restored["changed"] and yaml.safe_load(config_of(cluster.obj))["lldap"]["bindPW"] == SERVICE_PASSWORD + + +@pytest.mark.parametrize( + "env", + [ + [], + [{"name": "KEYCAPE_LLDAP_BIND_PW", "value": "literal"}], + [{"name": "KEYCAPE_LLDAP_BIND_PW", "valueFrom": {"secretKeyRef": {"name": "keycape-config", "key": "password"}}}], + ], +) +def test_clear_password_refuses_without_the_delivered_env(env): + cluster = ClusterWithDeployment(secret(), env) + m.run(args("apply", attended=True), cluster) + with pytest.raises(m.Refused, match="bind_env_not_delivered"): + m.run(args("inspect", "clear-password", version="42"), cluster) + + +def test_clear_password_refuses_while_the_human_bind_is_configured(): + with pytest.raises(m.Refused, match="clear_requires_service_bind"): + m.run(args("inspect", "clear-password"), ClusterWithDeployment(secret(), [m.BIND_ENV])) diff --git a/tests/test_service_bind_lane.py b/tests/test_service_bind_lane.py index 4e05f27..a0b429d 100644 --- a/tests/test_service_bind_lane.py +++ b/tests/test_service_bind_lane.py @@ -19,6 +19,9 @@ KV = 'platform/data/workloads/net-kingdom/directory-binds/svc-provisioner' AUTHELIA_CCR = 'CCR-2026-0053-net-kingdom-authelia-bind.yaml' AUTHELIA_KV = 'platform/data/workloads/net-kingdom/directory-binds/svc-authelia' BIND_LINE = 'user: uid=svc-authelia,ou=people,dc=netkingdom,dc=local' +KEYCAPE_CCR = 'CCR-2026-0054-net-kingdom-keycape-bind.yaml' +KEYCAPE_KV = 'platform/data/workloads/net-kingdom/directory-binds/svc-keycape' +KEYCAPE_DN = 'uid=svc-keycape,ou=people,dc=netkingdom,dc=local' pytestmark = pytest.mark.skipif(not (PLATFORM / 'credential-change-requests' / CCR).exists(), reason='railiance-platform checkout with CCR-2026-0052 required') @@ -72,8 +75,8 @@ class FakeBao: def build(tmp_path, monkeypatch, name='svc-provisioner'): - authelia = name == 'svc-authelia' - ccr_file = AUTHELIA_CCR if authelia else CCR + authelia, keycape = name == 'svc-authelia', name == 'svc-keycape' + ccr_file = AUTHELIA_CCR if authelia else KEYCAPE_CCR if keycape else CCR tool = load() platform = tmp_path / 'platform' (platform / 'credential-change-requests').mkdir(parents=True); (platform / 'openbao/policies').mkdir(parents=True) @@ -91,7 +94,8 @@ def build(tmp_path, monkeypatch, name='svc-provisioner'): path.write_text(json.dumps(ccr, indent=2) + '\n') digest = hashlib.sha256(path.read_bytes()).hexdigest() monkeypatch.setattr(tool, 'PLATFORM', platform) - bao = FakeBao(AUTHELIA_KV, 'net-kingdom-svc-authelia-bind-eso') if authelia else FakeBao() + bao = (FakeBao(AUTHELIA_KV, 'net-kingdom-svc-authelia-bind-eso') if authelia + else FakeBao(KEYCAPE_KV, 'net-kingdom-svc-keycape-bind-eso') if keycape else FakeBao()) monkeypatch.setattr(tool, 'bao', bao) monkeypatch.setattr(tool, 'data', lambda r: json.loads(r.stdout)) monkeypatch.setattr(tool, 'capabilities', lambda token, paths: json.loads( @@ -103,7 +107,8 @@ def build(tmp_path, monkeypatch, name='svc-provisioner'): if old.returncode != 0: bao(['write', path, '-'], payload=wanted) check(json.loads(bao(['read', '-format=json', path]).stdout)['data']) monkeypatch.setattr(tool, 'ensure', ensure) - live = {'value': VALUE, 'ready': 'True', 'bind_line': BIND_LINE} + # KeyCape: 'value' is bindPW in keycape-config, 'delivered' the ESO Secret (None until ESO runs). + live = {'value': VALUE, 'ready': 'True', 'bind_line': BIND_LINE, 'bind_dn': KEYCAPE_DN, 'delivered': None} def kube(kubeconfig, args, payload=None): verb = args[2:5] @@ -115,14 +120,23 @@ def build(tmp_path, monkeypatch, name='svc-provisioner'): 'ldap_password': base64.b64encode(live['value'].encode()).decode()}}).encode() if authelia and verb == ['get', 'configmap', 'authelia-config']: return json.dumps({'data': {'configuration.yml': 'authentication_backend:\n ldap:\n ' + live['bind_line'] + '\n'}}).encode() - if verb == ['get', 'externalsecret', 'authelia-secrets' if authelia else 'identity-provisioner-ldap']: + if keycape and verb == ['get', 'secret', 'keycape-config']: + config = ('issuer: https://kc.example.test\nlldap:\n url: ldap://lldap:3890\n' + f' bindDN: {live["bind_dn"]}\n bindPW: "{live["value"]}"\nclients: []\n') + return json.dumps({'data': {'config.yaml': base64.b64encode(config.encode()).decode(), + 'key.pem': base64.b64encode(b'unrelated').decode()}}).encode() + if keycape and verb == ['get', 'secret', 'keycape-lldap-bind']: + if live['delivered'] is None: raise subprocess.CalledProcessError(1, 'kubectl') + return json.dumps({'data': {'password': base64.b64encode(live['delivered'].encode()).decode()}}).encode() + external = 'authelia-secrets' if authelia else 'keycape-lldap-bind' if keycape else 'identity-provisioner-ldap' + if verb == ['get', 'externalsecret', external]: return json.dumps({'status': {'conditions': [{'type': 'Ready', 'status': live['ready']}]}}).encode() if verb[:2] == ['create', 'token']: return (args[4] + '|' + args[5].split('=', 1)[1]).encode() raise AssertionError(args) monkeypatch.setattr(tool, 'kube', kube) custody = tmp_path / 'custody'; (custody / 'lldap').mkdir(parents=True, mode=0o700); custody.chmod(0o700) - key = 'LLDAP_SVC_AUTHELIA_PASS' if authelia else 'LLDAP_SVC_PROVISIONER_PASS' + key = 'LLDAP_SVC_AUTHELIA_PASS' if authelia else 'LLDAP_SVC_KEYCAPE_PASS' if keycape else 'LLDAP_SVC_PROVISIONER_PASS' env = custody / 'lldap/secrets.env'; env.write_text(f'{key}={VALUE}\n'); env.chmod(0o600) return SimpleNamespace(tool=tool, bao=bao, live=live, digest=digest, custody=str(custody), ccr=path, name=name) @@ -138,6 +152,12 @@ def authelia(tmp_path, monkeypatch): return build(tmp_path, monkeypatch, 'svc-authelia') +@pytest.fixture +def keycape(tmp_path, monkeypatch): + if not (PLATFORM / 'credential-change-requests' / KEYCAPE_CCR).exists(): pytest.skip('CCR-2026-0054 required') + return build(tmp_path, monkeypatch, 'svc-keycape') + + def run(ns, action): receipt = {} if action == 'setup': ns.tool.setup(ns.name, ns.digest, ns.custody, 'kc', receipt) @@ -243,3 +263,36 @@ def test_authelia_manifest_matches_contract(): 'creationPolicy': 'Merge', 'deletionPolicy': 'Retain'} assert es['spec']['data'] == [{'secretKey': 'ldap_password', 'remoteRef': { 'key': 'workloads/net-kingdom/directory-binds/svc-authelia', 'property': 'LDAP_BIND_PASSWORD'}}] + + +def test_keycape_setup_reads_the_config_bind_then_verify_the_dedicated_secret(keycape): + r = run(keycape, 'setup') # the dedicated Secret does not exist yet + assert r['status'] == 'custody_and_reader_prepared' and KEYCAPE_KV in keycape.bao.writes + keycape.live['delivered'] = VALUE # ESO created keycape-lldap-bind + assert run(keycape, 'verify')['status'] == 'reader_and_delivery_verified' + keycape.live['value'] = '' # bindPW cleared from keycape-config: the delivered value alone counts + assert run(keycape, 'verify')['status'] == 'reader_and_delivery_verified' + + +def test_keycape_verify_refuses_delivery_differing_from_the_config(keycape): + run(keycape, 'setup') + keycape.live['delivered'] = OTHER + with pytest.raises(keycape.tool.Refused, match='kv_live_mismatch'): run(keycape, 'verify') + + +def test_keycape_setup_refuses_another_bind_account(keycape): + keycape.live['bind_dn'] = 'uid=admin,ou=people,dc=netkingdom,dc=local' + with pytest.raises(keycape.tool.Refused, match='live_bind_user'): run(keycape, 'setup') + assert keycape.bao.writes == [] + + +def test_keycape_manifest_creates_a_dedicated_secret(): + import yaml + docs = [d for d in yaml.safe_load_all((ROOT / 'sso-mfa/k8s/keycape/bind-eso.yaml').read_text()) if d] + sa, store, es = docs + assert sa['metadata'] == {'name': 'net-kingdom-svc-keycape-bind-eso', 'namespace': 'sso'} + assert store['spec']['provider']['vault']['auth']['kubernetes']['role'] == 'net-kingdom-svc-keycape-bind-eso' + assert es['spec']['target'] == {'template': {'metadata': {}}, 'name': 'keycape-lldap-bind', + 'creationPolicy': 'Owner', 'deletionPolicy': 'Retain'} + assert es['spec']['data'] == [{'secretKey': 'password', 'remoteRef': { + 'key': 'workloads/net-kingdom/directory-binds/svc-keycape', 'property': 'LDAP_BIND_PASSWORD'}}] diff --git a/tools/service-binds/bind-lane.py b/tools/service-binds/bind-lane.py index 988f70b..8f77ba0 100755 --- a/tools/service-binds/bind-lane.py +++ b/tools/service-binds/bind-lane.py @@ -45,6 +45,16 @@ LANES = { 'user: uid=svc-authelia,ou=people,dc=netkingdom,dc=local'), 'external_secret': 'authelia-secrets', }, + # KeyCape reads the bind from config.yaml in keycape-config until it takes + # KEYCAPE_LLDAP_BIND_PW (key-cape KEY-WP-0046); ESO owns a new dedicated Secret. + 'svc-keycape': { + 'ccr': 'CCR-2026-0054-net-kingdom-keycape-bind.yaml', + 'custody_key': 'LLDAP_SVC_KEYCAPE_PASS', + 'secret': 'keycape-lldap-bind', 'secret_key': 'password', 'username': 'svc-keycape', + 'bind_in_config_secret': ('keycape-config', 'config.yaml', + 'uid=svc-keycape,ou=people,dc=netkingdom,dc=local'), + 'external_secret': 'keycape-lldap-bind', + }, } BIND_NAMES = ('svc-authelia', 'svc-keycape', 'svc-privacyidea', 'svc-provisioner') FIELD = 'LDAP_BIND_PASSWORD' @@ -98,9 +108,38 @@ def kube(kubeconfig, args, payload=None): check=True, capture_output=True).stdout -def live_secret(kubeconfig, lane): - raw = json.loads(kube(kubeconfig, ['-n', 'sso', 'get', 'secret', lane['secret'], '-o', 'json'])).get('data', {}) +def secret_data(kubeconfig, name): + return json.loads(kube(kubeconfig, ['-n', 'sso', 'get', 'secret', name, '-o', 'json'])).get('data', {}) + + +def decoded(raw, key): + return base64.b64decode(raw.get(key, ''), validate=True).decode() + + +def configured_bind(kubeconfig, lane): + """The bindPW in a service's config Secret, after checking it binds the lane's account.""" + import yaml + name, entry, dn = lane['bind_in_config_secret'] + config = yaml.safe_load(decoded(secret_data(kubeconfig, name), entry)) + lldap = config.get('lldap') if isinstance(config, dict) else None + if not isinstance(lldap, dict) or lldap.get('bindDN') != dn: raise Refused('live_bind_user') + return lldap.get('bindPW') or '' + + +def live_secret(kubeconfig, lane, delivered=True): + """The bind value in use. For a config-embedded bind, setup (delivered=False) reads the + config; verify reads the delivered Secret, which must equal the config while both exist.""" key = lane.get('secret_key', 'password') + if 'bind_in_config_secret' in lane: + configured = configured_bind(kubeconfig, lane) + if not delivered: + if not configured: raise Refused('live_secret_shape') + return configured + value = decoded(secret_data(kubeconfig, lane['secret']), key) + if not value: raise Refused('live_secret_shape') + if configured and configured != value: raise Refused('kv_live_mismatch') + return value + raw = secret_data(kubeconfig, lane['secret']) if 'bind_user_config' in lane: name, entry, line = lane['bind_user_config'] config = json.loads(kube(kubeconfig, ['-n', 'sso', 'get', 'configmap', name, '-o', 'json'])).get('data', {}) @@ -130,7 +169,7 @@ def setup(name, expected, root, kubeconfig, receipt): receipt['phase'] = 'identity'; attended_platform_admin() receipt['phase'] = 'custody_equals_live' value = custody_value(root, lane) - if live_secret(kubeconfig, lane) != value: raise Refused('custody_drift') + if live_secret(kubeconfig, lane, delivered=False) != value: raise Refused('custody_drift') receipt['phase'] = 'coding_agent_fence' current = data(bao(['read', '-format=json', BOUNDARY]))['data']['policy']; added = '' for path in (lane['kv_path'], lane['kv_path'].replace('/data/', '/metadata/', 1)): diff --git a/workplans/NK-WP-0056-bounded-executor-path.md b/workplans/NK-WP-0056-bounded-executor-path.md index 68d0aac..2a99638 100644 --- a/workplans/NK-WP-0056-bounded-executor-path.md +++ b/workplans/NK-WP-0056-bounded-executor-path.md @@ -182,6 +182,17 @@ document, and rendering all of `config.yaml` from OpenBao would move every KeyCa equal, so behaviour is unchanged. Verify, restart, admin login. 4. Remove `bindPW` from `keycape-config` (`directory-bind-rollout.py --operation clear-password`, guarded on uid/resourceVersion); `create-secrets.py` stops writing it. Rollback at any step: re-add the value or drop + +**2026-10-31, founder decision: environment override, implemented by claude-code in key-cape.** Done in source: +key-cape `e2b5fa1` (KEY-WP-0046-T01; image `main-e2b5fa1` = `sha256:9e082dca…6f8d`, built by the Forgejo workflow; +its acceptance job also fails on the previous commit `a1ab8ff`). NetKingdom: CCR-2026-0054 (proposed), +`sso-mfa/k8s/keycape/bind-eso.yaml` (dedicated Secret `keycape-lldap-bind`, `creationPolicy: Owner`), +`bind-lane.py svc-keycape` (setup reads `bindPW` from `keycape-config` and requires `bindDN` = `svc-keycape`; +verify reads the dedicated Secret and requires it to equal `bindPW` while present), `directory-bind-rollout.py +--operation clear-password` (only with the service bind and the delivered env; `switch` restores), and +`deployment.yaml` pinned to the new image with the `KEYCAPE_LLDAP_BIND_PW` env. The new image also carries +key-cape 67a0869..e2c5a94 (online token status, inactive while `tokenStatus` is unset) and identity-facts. +Founder order: approve, setup, ESO apply, verify, deployment apply (image + env), login, clear-password, restart, login. the env. ## Synthetic verification instead of a founder sign-in