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 <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:
parent
6aae759043
commit
6c7c37aa70
7 changed files with 255 additions and 16 deletions
|
|
@ -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]))
|
||||
|
|
|
|||
|
|
@ -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'}}]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue