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
248 lines
9.4 KiB
Python
248 lines
9.4 KiB
Python
#!/usr/bin/env python3
|
|
"""Switch KeyCape's LLDAP bind from the human admin to svc-keycape (NK-WP-0054-T05).
|
|
|
|
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. `--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,
|
|
`dry-run` sends a server-side dry-run patch, `apply` writes and needs `--attended` plus the observed
|
|
uid/resourceVersion. KeyCape reads the change after `kubectl -n sso rollout restart deploy/keycape`.
|
|
No Secret value, parser message or Kubernetes error text leaves the process.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import base64
|
|
import copy
|
|
import getpass
|
|
import importlib.util
|
|
import json
|
|
from pathlib import Path
|
|
|
|
import yaml
|
|
|
|
ROOT = Path(__file__).resolve().parent
|
|
|
|
|
|
def module(name, path):
|
|
spec = importlib.util.spec_from_file_location(name, path)
|
|
result = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(result)
|
|
return result
|
|
|
|
|
|
# Reuse the owner lane's contained kubectl, refusal type and cluster pin.
|
|
lane = module("portal_rollout", ROOT / "portal-client-rollout.py")
|
|
pin = lane.pin
|
|
Refused = lane.Refused
|
|
require = lane.require
|
|
custody = module(
|
|
"keycape_bind_custody", ROOT.parents[1] / "bootstrap/apply-service-secrets.py"
|
|
)
|
|
|
|
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):
|
|
with custody.custody(Path(root), ["lldap"]) as directories:
|
|
return custody.literal(
|
|
custody.read_env(directories["lldap"]), "LLDAP_SVC_KEYCAPE_PASS"
|
|
)
|
|
|
|
|
|
def _lldap(secret):
|
|
raw = base64.b64decode(secret["data"]["config.yaml"], validate=True).decode("utf-8")
|
|
if any(
|
|
isinstance(t, (yaml.tokens.AnchorToken, yaml.tokens.AliasToken))
|
|
for t in yaml.scan(raw)
|
|
):
|
|
raise Refused("yaml_alias_or_anchor")
|
|
config = yaml.load(raw, Loader=pin.UniqueLoader)
|
|
require(
|
|
isinstance(config, dict) and isinstance(config.get("lldap"), dict),
|
|
"lldap_mapping_required",
|
|
)
|
|
node = next(v for k, v in yaml.compose(raw).value if k.value == "lldap")
|
|
fields = {k.value: v for k, v in node.value}
|
|
for name in ("bindDN", "bindPW"):
|
|
require(
|
|
isinstance(fields.get(name), yaml.nodes.ScalarNode), "bind_scalars_required"
|
|
)
|
|
return raw, config, fields
|
|
|
|
|
|
def replace(secret, dn, password):
|
|
"""Return (base64 config, changed, prior) with only bindDN and bindPW replaced."""
|
|
raw, config, fields = _lldap(secret)
|
|
current = config["lldap"]["bindDN"]
|
|
prior = (
|
|
"service"
|
|
if current == SERVICE_DN
|
|
else "human"
|
|
if current == HUMAN_DN
|
|
else "other"
|
|
)
|
|
require(prior != "other", "unexpected_bind_dn_requires_reconciliation")
|
|
if current == dn and config["lldap"]["bindPW"] == password:
|
|
return secret["data"]["config.yaml"], False, prior
|
|
# Replace later spans first so earlier indexes stay valid.
|
|
spans = sorted(
|
|
((fields["bindDN"], dn), (fields["bindPW"], password)),
|
|
key=lambda p: p[0].start_mark.index,
|
|
reverse=True,
|
|
)
|
|
updated = raw
|
|
for scalar, value in spans:
|
|
updated = (
|
|
updated[: scalar.start_mark.index]
|
|
+ json.dumps(value)
|
|
+ updated[scalar.end_mark.index :]
|
|
)
|
|
expected = copy.deepcopy(config)
|
|
expected["lldap"].update(bindDN=dn, bindPW=password)
|
|
require(
|
|
yaml.load(updated, Loader=pin.UniqueLoader) == expected,
|
|
"unrelated_configuration_changed",
|
|
)
|
|
return base64.b64encode(updated.encode()).decode(), True, prior
|
|
|
|
|
|
def run(args, kubectl=None, prompt=getpass.getpass):
|
|
kubectl = kubectl or lane.kubectl
|
|
cluster = kubectl(["get", "namespace", "kube-system", "-o", "json"])
|
|
require(cluster["metadata"]["uid"] == lane.CLUSTER_UID, "cluster_identity_changed")
|
|
before = kubectl(["-n", "sso", "get", "secret", "keycape-config", "-o", "json"])
|
|
require(not before["metadata"].get("ownerReferences"), "controller_owned_secret")
|
|
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,
|
|
prompt("Human admin password for the KeyCape bind rollback: "),
|
|
)
|
|
require(bool(password) and "\n" not in password, "password_required")
|
|
encoded, changed, prior = replace(before, dn, password)
|
|
receipt = {
|
|
"schema": "netkingdom.keycape-directory-bind.v1",
|
|
"task": "NK-WP-0056-T04" if args.operation == "clear-password" else "NK-WP-0054-T05",
|
|
"target": "sso/keycape-config",
|
|
"before": metadata,
|
|
"operation": args.operation,
|
|
"prior_bind": prior,
|
|
"change_needed": changed,
|
|
"mode": args.mode,
|
|
"values_emitted": False,
|
|
"changed": False,
|
|
}
|
|
if args.mode == "inspect":
|
|
return receipt
|
|
if args.mode == "apply":
|
|
require(args.attended, "attended_operator_required")
|
|
require(
|
|
metadata
|
|
== {
|
|
"uid": args.expected_uid,
|
|
"resource_version": args.expected_resource_version,
|
|
},
|
|
"observed_revision_changed",
|
|
)
|
|
if changed:
|
|
patch = [
|
|
{"op": "test", "path": "/metadata/uid", "value": metadata["uid"]},
|
|
{
|
|
"op": "test",
|
|
"path": "/metadata/resourceVersion",
|
|
"value": metadata["resource_version"],
|
|
},
|
|
{"op": "replace", "path": "/data/config.yaml", "value": encoded},
|
|
]
|
|
command = [
|
|
"-n",
|
|
"sso",
|
|
"patch",
|
|
"secret",
|
|
"keycape-config",
|
|
"--type=json",
|
|
"--patch-file=/dev/stdin",
|
|
"-o",
|
|
"json",
|
|
]
|
|
if args.mode == "dry-run":
|
|
command += ["--dry-run=server"]
|
|
result = kubectl(command, patch)
|
|
require(
|
|
result["data"] == dict(before["data"], **{"config.yaml": encoded}),
|
|
"patch_response_mismatch",
|
|
)
|
|
if args.mode == "apply":
|
|
after = kubectl(["-n", "sso", "get", "secret", "keycape-config", "-o", "json"])
|
|
require(
|
|
after["metadata"]["uid"] == metadata["uid"]
|
|
and after["data"] == dict(before["data"], **{"config.yaml": encoded}),
|
|
"readback_mismatch_stop_without_stale_replay",
|
|
)
|
|
receipt.update(
|
|
changed=changed, after=pin.safe_metadata(after), restart_required=changed
|
|
)
|
|
receipt.update(
|
|
unrelated_config_bytes_preserved=True, other_secret_data_unchanged=True
|
|
)
|
|
return receipt
|
|
|
|
|
|
def main(argv=None, kubectl=None, prompt=getpass.getpass):
|
|
parser = argparse.ArgumentParser(description=__doc__.split("\n", 1)[0])
|
|
parser.add_argument(
|
|
"mode", nargs="?", default="inspect", choices=["inspect", "dry-run", "apply"]
|
|
)
|
|
parser.add_argument("--operation", default="switch", choices=["switch", "revert", "clear-password"])
|
|
parser.add_argument(
|
|
"--custody-root", help="private custody copy; required for switch"
|
|
)
|
|
parser.add_argument("--expected-uid")
|
|
parser.add_argument("--expected-resource-version")
|
|
parser.add_argument("--attended", action="store_true", help="required for apply")
|
|
args = parser.parse_args(argv)
|
|
try:
|
|
if args.operation == "switch":
|
|
require(bool(args.custody_root), "custody_root_required")
|
|
print(json.dumps(run(args, kubectl, prompt), sort_keys=True))
|
|
return 0
|
|
except (Refused, custody.Refused) as error:
|
|
print(json.dumps({"status": "refused", "reason": str(error) or "refused"}))
|
|
except pin.IssuerPinError as error:
|
|
print(json.dumps({"status": "refused", "reason": str(error)}))
|
|
except Exception: # noqa: BLE001 - contain any error that could carry Secret material
|
|
# Parser and Kubernetes errors can contain full Secret material.
|
|
print(json.dumps({"status": "failed", "reason": "contained_operation_failed"}))
|
|
return 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|