Add contained issuer-only configuration check and revision-guarded pin

Assistant: codex
Assistant-Model: gpt-5.6-luna
Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc
This commit is contained in:
tegwick 2026-09-09 00:23:21 +02:00
parent 46455439cf
commit ad9979b159
3 changed files with 305 additions and 1 deletions

View file

@ -11,7 +11,11 @@ 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:
@ -52,6 +56,135 @@ LLDAP_REQUIRED = {
"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)
@ -142,8 +275,12 @@ def verify(config: dict[str, Any]) -> None:
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("mode", choices=("patch", "verify"))
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)