Assistant: codex Assistant-Model: gpt-5.6-luna Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc
292 lines
12 KiB
Python
292 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
"""Patch or verify non-secret KeyCape live config requirements.
|
|
|
|
The script reads a Kubernetes Secret JSON object from stdin. It never prints the
|
|
decoded KeyCape config or private key; stdout is either a JSON merge patch for
|
|
kubectl, or a short non-secret verification message.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import base64
|
|
import json
|
|
import copy
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
from datetime import datetime, timezone
|
|
from typing import Any
|
|
|
|
try:
|
|
import yaml
|
|
except ImportError as exc: # pragma: no cover - operator environment guard
|
|
raise SystemExit("PyYAML is required: install python3-yaml or run in the NetKingdom tool environment") from exc
|
|
|
|
|
|
OPENBAO_CLIENT = {
|
|
"clientId": "openbao-admin",
|
|
"displayName": "Railiance OpenBao Admin",
|
|
"redirectUris": [
|
|
"http://localhost:8250/oidc/callback",
|
|
"http://127.0.0.1:8250/oidc/callback",
|
|
"http://127.0.0.1:18200/ui/vault/auth/netkingdom/oidc/callback",
|
|
"https://bao.coulomb.social/ui/vault/auth/netkingdom/oidc/callback",
|
|
"https://bao.coulomb.social/ui/vault/auth/keycape/oidc/callback",
|
|
],
|
|
"allowedScopes": ["openid", "profile", "email", "groups"],
|
|
"grantTypes": ["authorization_code"],
|
|
"clientType": "public",
|
|
}
|
|
|
|
RAPP_QONTO_CLIENT = {
|
|
"clientId": "rapp-qonto-client",
|
|
"displayName": "rapp-qonto workload",
|
|
"allowedScopes": ["qonto:read"],
|
|
"grantTypes": ["client_credentials"],
|
|
"clientType": "confidential",
|
|
"secretRef": "env:KEYCAPE_RAPP_QONTO_CLIENT_SECRET",
|
|
"serviceSubject": "rapp-qonto",
|
|
"tenant": "tenant:friendly:binky",
|
|
"roles": ["qonto-reader"],
|
|
}
|
|
|
|
LLDAP_REQUIRED = {
|
|
"userOU": "ou=people",
|
|
"groupOU": "ou=groups",
|
|
}
|
|
|
|
# Verified against an actual signed upstream token on 2026-09-08 (KEY-WP-0013).
|
|
VERIFIED_UPSTREAM_ISSUER = "https://auth.coulomb.social"
|
|
|
|
|
|
class IssuerPinError(Exception):
|
|
"""Only fixed, non-secret reason codes may leave the contained operation."""
|
|
|
|
|
|
class UniqueLoader(yaml.SafeLoader):
|
|
pass
|
|
|
|
|
|
def unique_mapping(loader: Any, node: Any) -> dict[str, Any]:
|
|
result = {}
|
|
for key_node, value_node in node.value:
|
|
key = loader.construct_object(key_node, deep=True)
|
|
if not isinstance(key, str) or key in result:
|
|
raise IssuerPinError("ambiguous_yaml_mapping")
|
|
result[key] = loader.construct_object(value_node, deep=True)
|
|
return result
|
|
|
|
|
|
UniqueLoader.add_constructor(yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, unique_mapping)
|
|
|
|
|
|
def issuer_document(secret: dict[str, Any]) -> tuple[str, dict[str, Any], Any]:
|
|
"""Parse privately; never include a parser exception or document in output."""
|
|
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 IssuerPinError("yaml_alias_or_anchor")
|
|
config = yaml.load(raw, Loader=UniqueLoader)
|
|
if not isinstance(config, dict) or not isinstance(config.get('authelia'), dict):
|
|
raise IssuerPinError("unexpected_configuration_shape")
|
|
node = yaml.compose(raw)
|
|
authelia = next(v for k, v in node.value if k.value == 'authelia')
|
|
if authelia.flow_style or not authelia.value:
|
|
raise IssuerPinError("unsupported_authelia_mapping")
|
|
return raw, config, authelia
|
|
|
|
|
|
def issuer_replacement(secret: dict[str, Any]) -> tuple[str, bool, str]:
|
|
raw, config, authelia = issuer_document(secret)
|
|
old = config['authelia'].get('issuer')
|
|
prior = 'absent' if 'issuer' not in config['authelia'] else 'matching' if old == VERIFIED_UPSTREAM_ISSUER else 'different'
|
|
if old == VERIFIED_UPSTREAM_ISSUER:
|
|
return secret['data']['config.yaml'], False, prior
|
|
field = next((v for k, v in authelia.value if k.value == 'issuer'), None)
|
|
if field is not None:
|
|
if not isinstance(field, yaml.nodes.ScalarNode):
|
|
raise IssuerPinError("unsupported_issuer_value")
|
|
updated = raw[:field.start_mark.index] + json.dumps(VERIFIED_UPSTREAM_ISSUER) + raw[field.end_mark.index:]
|
|
else:
|
|
index = authelia.start_mark.index - authelia.start_mark.column
|
|
indent = ' ' * authelia.start_mark.column
|
|
updated = raw[:index] + indent + 'issuer: ' + json.dumps(VERIFIED_UPSTREAM_ISSUER) + '\n' + raw[index:]
|
|
expected = copy.deepcopy(config)
|
|
expected['authelia']['issuer'] = VERIFIED_UPSTREAM_ISSUER
|
|
if yaml.load(updated, Loader=UniqueLoader) != expected:
|
|
raise IssuerPinError("unexpected_configuration_change")
|
|
return base64.b64encode(updated.encode()).decode('ascii'), True, prior
|
|
|
|
|
|
def contained_kube(args: list[str], payload: Any = None) -> Any:
|
|
# Secret JSON and patch bodies stay in process memory and child stdin.
|
|
# In particular, never use -p <patch>, a shell pipeline, or inherited stderr.
|
|
result = subprocess.run(
|
|
['kubectl', '--request-timeout=20s', '-n', 'sso', *args],
|
|
input=None if payload is None else json.dumps(payload),
|
|
capture_output=True, text=True, timeout=30, check=False,
|
|
)
|
|
if result.returncode:
|
|
raise IssuerPinError("kubernetes_operation_failed")
|
|
return json.loads(result.stdout)
|
|
|
|
|
|
def safe_metadata(obj: dict[str, Any]) -> dict[str, str]:
|
|
metadata = obj['metadata']
|
|
uid, revision = metadata['uid'], metadata['resourceVersion']
|
|
if not re.fullmatch(r'[0-9a-f-]{36}', uid) or not re.fullmatch(r'[0-9]+', revision):
|
|
raise IssuerPinError("unexpected_metadata_shape")
|
|
return {'uid': uid, 'resource_version': revision}
|
|
|
|
|
|
def issuer_live(mode: str, expected_uid: str | None = None, expected_revision: str | None = None) -> dict[str, Any]:
|
|
if mode == 'issuer-pin-live' and (not expected_uid or not expected_revision):
|
|
raise IssuerPinError("pin_requires_observed_uid_and_revision")
|
|
before = contained_kube(['get', 'secret', 'keycape-config', '-o', 'json'])
|
|
if before.get('kind') != 'Secret' or before['metadata'].get('name') != 'keycape-config' or before['metadata'].get('namespace') != 'sso':
|
|
raise IssuerPinError("unexpected_target")
|
|
metadata = safe_metadata(before)
|
|
if mode == 'issuer-pin-live' and metadata != {'uid': expected_uid, 'resource_version': expected_revision}:
|
|
raise IssuerPinError("observed_revision_changed")
|
|
replacement, changed, prior = issuer_replacement(before)
|
|
receipt = {'schema': 'netkingdom.keycape-issuer-pin.v1', 'mode': mode,
|
|
'observed_at': datetime.now(timezone.utc).isoformat(),
|
|
'target': 'sso/keycape-config', 'verified_issuer': VERIFIED_UPSTREAM_ISSUER,
|
|
'before': metadata, 'prior_pin_state': prior,
|
|
'issuer_matches': not changed, 'config_changed': False,
|
|
'secret_values_emitted': False, 'deployment_changed': False}
|
|
if mode == 'issuer-check-live':
|
|
return receipt
|
|
if changed:
|
|
patch = [
|
|
{'op': 'test', 'path': '/metadata/uid', 'value': expected_uid},
|
|
{'op': 'test', 'path': '/metadata/resourceVersion', 'value': expected_revision},
|
|
{'op': 'replace', 'path': '/data/config.yaml', 'value': replacement},
|
|
]
|
|
contained_kube(['patch', 'secret', 'keycape-config', '--type=json', '--patch-file=/dev/stdin', '-o', 'json'], patch)
|
|
after = contained_kube(['get', 'secret', 'keycape-config', '-o', 'json'])
|
|
expected_data = dict(before['data'], **{'config.yaml': replacement})
|
|
if after['metadata']['uid'] != metadata['uid'] or after['data'] != expected_data:
|
|
raise IssuerPinError("readback_mismatch_stop_without_replaying_stale_config")
|
|
receipt.update({'after': safe_metadata(after), 'issuer_matches': True,
|
|
'config_changed': changed, 'other_secret_data_unchanged': True,
|
|
'unrelated_config_bytes_preserved': True})
|
|
return receipt
|
|
|
|
|
|
def issuer_main(args: Any) -> int:
|
|
try:
|
|
receipt = issuer_live(args.mode, args.expected_uid, args.expected_resource_version)
|
|
except IssuerPinError as exc:
|
|
receipt = {'schema': 'netkingdom.keycape-issuer-pin.v1', 'status': 'failed', 'reason': str(exc)}
|
|
except Exception:
|
|
# Kubernetes/YAML exceptions may contain entire response/config fragments.
|
|
receipt = {'schema': 'netkingdom.keycape-issuer-pin.v1', 'status': 'failed', 'reason': 'contained_operation_failed'}
|
|
print(json.dumps(receipt, sort_keys=True))
|
|
return 1 if receipt.get('status') == 'failed' else 0
|
|
|
|
|
|
def load_config() -> dict[str, Any]:
|
|
secret = json.load(sys.stdin)
|
|
encoded_config = (secret.get("data") or {}).get("config.yaml")
|
|
if not encoded_config:
|
|
raise SystemExit("keycape-config Secret does not contain data.config.yaml")
|
|
try:
|
|
config_text = base64.b64decode(encoded_config).decode("utf-8")
|
|
except Exception as exc: # noqa: BLE001 - concise operator error
|
|
raise SystemExit(f"could not decode data.config.yaml: {exc}") from exc
|
|
config = yaml.safe_load(config_text) or {}
|
|
if not isinstance(config, dict):
|
|
raise SystemExit("KeyCape config.yaml must decode to a YAML mapping")
|
|
return config
|
|
|
|
|
|
def client_errors(config: dict[str, Any], required: dict[str, Any]) -> list[str]:
|
|
clients = config.get("clients")
|
|
if not isinstance(clients, list):
|
|
return ["clients must be a list"]
|
|
target = next(
|
|
(client for client in clients if isinstance(client, dict) and client.get("clientId") == required["clientId"]),
|
|
None,
|
|
)
|
|
if target is None:
|
|
return [f"missing {required['clientId']} client"]
|
|
|
|
errors: list[str] = []
|
|
for key, expected in required.items():
|
|
if key in ("redirectUris", "allowedScopes", "grantTypes", "roles"):
|
|
missing = sorted(set(expected) - set(target.get(key) or []))
|
|
if missing:
|
|
errors.append(f"{required['clientId']}.{key} missing: {', '.join(missing)}")
|
|
elif target.get(key) != expected:
|
|
errors.append(f"{required['clientId']}.{key} should be {expected!r}")
|
|
return errors
|
|
|
|
|
|
def upsert_client(config: dict[str, Any], required: dict[str, Any]) -> dict[str, Any]:
|
|
clients = config.get("clients")
|
|
if not isinstance(clients, list):
|
|
clients = []
|
|
config["clients"] = clients
|
|
for index, client in enumerate(clients):
|
|
if isinstance(client, dict) and client.get("clientId") == required["clientId"]:
|
|
clients[index] = dict(required)
|
|
return config
|
|
clients.append(dict(required))
|
|
return config
|
|
|
|
|
|
def lldap_errors(config: dict[str, Any]) -> list[str]:
|
|
lldap = config.get("lldap")
|
|
if not isinstance(lldap, dict):
|
|
return ["lldap must be a mapping"]
|
|
return [
|
|
f"lldap.{key} should be {expected!r}"
|
|
for key, expected in LLDAP_REQUIRED.items()
|
|
if lldap.get(key) != expected
|
|
]
|
|
|
|
|
|
def enforce_lldap_defaults(config: dict[str, Any]) -> dict[str, Any]:
|
|
lldap = config.get("lldap")
|
|
if not isinstance(lldap, dict):
|
|
lldap = {}
|
|
config["lldap"] = lldap
|
|
lldap.update(LLDAP_REQUIRED)
|
|
return config
|
|
|
|
|
|
def render_patch(config: dict[str, Any]) -> None:
|
|
updated = enforce_lldap_defaults(upsert_client(upsert_client(config, OPENBAO_CLIENT), RAPP_QONTO_CLIENT))
|
|
config_text = yaml.safe_dump(updated, sort_keys=False)
|
|
encoded = base64.b64encode(config_text.encode("utf-8")).decode("ascii")
|
|
json.dump({"data": {"config.yaml": encoded}}, sys.stdout, separators=(",", ":"))
|
|
sys.stdout.write("\n")
|
|
|
|
|
|
def verify(config: dict[str, Any]) -> None:
|
|
errors = client_errors(config, OPENBAO_CLIENT) + client_errors(config, RAPP_QONTO_CLIENT) + lldap_errors(config)
|
|
if errors:
|
|
for error in errors:
|
|
print(f"[FAIL] {error}")
|
|
raise SystemExit(1)
|
|
print("[PASS] openbao-admin and rapp-qonto clients and LLDAP OU lookup settings are present")
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("mode", choices=("patch", "verify", "issuer-check-live", "issuer-pin-live"))
|
|
parser.add_argument("--expected-uid")
|
|
parser.add_argument("--expected-resource-version")
|
|
args = parser.parse_args()
|
|
if args.mode.startswith('issuer-'):
|
|
raise SystemExit(issuer_main(args))
|
|
config = load_config()
|
|
if args.mode == "patch":
|
|
render_patch(config)
|
|
else:
|
|
verify(config)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|