NK-WP-0056-T04: svc-keycape bind lane (source only, CCR-2026-0054)
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s

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 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 896183@bnt-lap001
Assistant-Session: 3a18c01a-3205-4d02-94db-9efff25649a2
This commit is contained in:
tegwick 2026-10-31 10:31:30 +01:00
parent 6aae759043
commit 6c7c37aa70
7 changed files with 255 additions and 16 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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'}}]

View file

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

View file

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