docs: preserve reviewed NetKingdom session artifacts
This commit is contained in:
parent
8231b2a8f5
commit
4735d6755f
48 changed files with 6488 additions and 0 deletions
|
|
@ -0,0 +1,7 @@
|
|||
#!/usr/bin/env bash
|
||||
# First creation only. Existing KeyCape configuration requires a scoped owner update.
|
||||
set +x
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
exec python3 "$SCRIPT_DIR/create-secrets.py" "$@"
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
import base64, importlib.util, json, os, secrets, socket, subprocess, tempfile, time
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
import yaml
|
||||
import http.server, ssl, threading, urllib.request, urllib.error, datetime, ipaddress
|
||||
from cryptography import x509
|
||||
from cryptography.x509.oid import NameOID
|
||||
import argparse
|
||||
parser=argparse.ArgumentParser(description='Exercise the pinned image and verifier over loopback HTTPS using synthetic credentials only.')
|
||||
parser.add_argument('--receipt', required=True, type=Path)
|
||||
args=parser.parse_args()
|
||||
spec=importlib.util.spec_from_file_location('rollout',Path(__file__).with_name('approval-clients-rollout.py'))
|
||||
m=importlib.util.module_from_spec(spec); spec.loader.exec_module(m)
|
||||
m.verify_artifact()
|
||||
def call(args):
|
||||
p=subprocess.run(args,capture_output=True,timeout=45)
|
||||
if p.returncode: raise RuntimeError('contained_docker_command_failed')
|
||||
return p
|
||||
sock=socket.socket(); sock.bind(('127.0.0.1',0)); port=sock.getsockname()[1]; sock.close()
|
||||
class Proxy(http.server.BaseHTTPRequestHandler):
|
||||
def log_message(self, *args): pass
|
||||
def handle_proxy(self):
|
||||
body = self.rfile.read(int(self.headers.get('Content-Length', 0))) if self.command == 'POST' else None
|
||||
req = urllib.request.Request(f'http://127.0.0.1:{port}'+self.path, data=body,
|
||||
headers={k:self.headers[k] for k in ['Content-Type','Authorization'] if k in self.headers})
|
||||
try:
|
||||
response=urllib.request.urlopen(req,timeout=20)
|
||||
except urllib.error.HTTPError as error: response=error
|
||||
with response:
|
||||
self.send_response(response.code)
|
||||
self.send_header('Content-Type', response.headers.get('Content-Type','application/json'))
|
||||
self.end_headers(); self.wfile.write(response.read())
|
||||
do_GET=handle_proxy
|
||||
do_POST=handle_proxy
|
||||
server=http.server.ThreadingHTTPServer(('127.0.0.1',0),Proxy)
|
||||
https_port=server.server_address[1]
|
||||
name='keycape-approval-exercise-'+secrets.token_hex(5)
|
||||
with tempfile.TemporaryDirectory(prefix='keycape-private-exercise-') as temp:
|
||||
root=Path(temp)
|
||||
key=rsa.generate_private_key(public_exponent=65537,key_size=2048)
|
||||
(root/'key.pem').write_bytes(key.private_bytes(serialization.Encoding.PEM,serialization.PrivateFormat.PKCS8,serialization.NoEncryption()))
|
||||
os.chmod(root/'key.pem',0o600)
|
||||
name_attr=x509.Name([x509.NameAttribute(NameOID.COMMON_NAME,'KeyCape local exercise')])
|
||||
cert=(x509.CertificateBuilder().subject_name(name_attr).issuer_name(name_attr).public_key(key.public_key())
|
||||
.serial_number(x509.random_serial_number()).not_valid_before(datetime.datetime.now(datetime.timezone.utc)-datetime.timedelta(minutes=1))
|
||||
.not_valid_after(datetime.datetime.now(datetime.timezone.utc)+datetime.timedelta(hours=1))
|
||||
.add_extension(x509.SubjectAlternativeName([x509.IPAddress(ipaddress.ip_address('127.0.0.1'))]),False)
|
||||
.add_extension(x509.BasicConstraints(ca=True,path_length=None),True))
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
(root/'cert.pem').write_bytes(cert.sign(key,hashes.SHA256()).public_bytes(serialization.Encoding.PEM))
|
||||
context=ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER); context.load_cert_chain(root/'cert.pem',root/'key.pem')
|
||||
server.socket=context.wrap_socket(server.socket,server_side=True)
|
||||
threading.Thread(target=server.serve_forever,daemon=True).start()
|
||||
config=yaml.safe_load((m.KEYCAPE/'config/dev-config.yaml').read_text())
|
||||
config.update(issuer=f'https://127.0.0.1:{https_port}')
|
||||
config['authelia']['issuer']='https://auth.coulomb.social'
|
||||
config['clients'].extend(m.registrations())
|
||||
(root/'config.yaml').write_text(yaml.safe_dump(config,sort_keys=False))
|
||||
os.chmod(root/'config.yaml',0o600)
|
||||
values={c['clientId']:secrets.token_urlsafe(48) for c in m.registrations()}
|
||||
(root/'env').write_text('KEYCAPE_CONFIG=/etc/keycape/config.yaml\n'+''.join(c['secretRef'].removeprefix('env:')+'='+values[c['clientId']]+'\n' for c in m.registrations()))
|
||||
os.chmod(root/'env',0o600)
|
||||
created=False
|
||||
try:
|
||||
call(['docker','run','-d','--rm','--name',name,'--user',str(os.getuid()),'--publish',f'127.0.0.1:{port}:8080',
|
||||
'--env-file',str(root/'env'),'--mount','type=bind,source='+temp+',target=/etc/keycape,readonly',m.IMAGE])
|
||||
created=True
|
||||
with patch.object(m,'ISSUER',config['issuer']), patch.dict(os.environ,{'SSL_CERT_FILE':str(root/'cert.pem')}):
|
||||
for _ in range(30):
|
||||
try:
|
||||
status,_=m.http('/.well-known/openid-configuration')
|
||||
if status==200: break
|
||||
except Exception: pass
|
||||
time.sleep(.5)
|
||||
else: raise RuntimeError('scratch_keycape_not_ready')
|
||||
def fake_get(kube,kind,name):
|
||||
index=m.SECRET_NAMES.index(name)
|
||||
return {'data': {'client-secret':base64.b64encode(values[m.IDS[index]].encode()).decode()}}
|
||||
receipt={}
|
||||
with patch.object(m,'get',side_effect=fake_get):
|
||||
m.acceptance([],receipt)
|
||||
receipt.update(target='disposable pinned KeyCape image; synthetic keys and clients only',
|
||||
pinned_native_cli_verification='passed against loopback HTTPS with synthetic credentials',cleanup_complete=False)
|
||||
finally:
|
||||
if created: call(['docker','stop','--time=5',name])
|
||||
server.shutdown(); server.server_close()
|
||||
receipt['cleanup_complete']=True
|
||||
fd=os.open(args.receipt,os.O_WRONLY|os.O_CREAT|os.O_EXCL,0o600)
|
||||
with os.fdopen(fd,'w') as out: out.write(json.dumps(receipt,indent=2)+'\n')
|
||||
print('Pinned-image scratch acceptance passed; synthetic credentials removed.')
|
||||
|
|
@ -0,0 +1,312 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Register or remove exactly one KeyCape client from a SecurityCase, without exporting Secret values.
|
||||
|
||||
Entry point of the playbook capability net-kingdom.keycape-client-registration,
|
||||
composed by the SecurityCaseType oidc-browser-relying-party
|
||||
(security-case-types/, canon security-case-types_v0.1). It generalizes the
|
||||
per-client helpers in this directory (portal-client-rollout,
|
||||
register-coulomb-social, vergabe-client-rollout,
|
||||
secrets-engine-openbao-client-rollout) and keeps their safeguards:
|
||||
|
||||
- contained kubectl, cluster-identity pin and verified upstream issuer pin
|
||||
- byte-preserving merge: only this client's lines change; every other config
|
||||
byte, the signing key and unrelated clients are preserved
|
||||
- exact match: absent -> add; identical -> noop; drift, duplicates or a shared
|
||||
secretRef -> refuse (update = remove, then add, each accepted)
|
||||
- compare-and-swap on the Secret's uid and resourceVersion, then read-back
|
||||
- confidential clients: the KeyCape Deployment must already bind the client's
|
||||
secret env var (delivered from custody), or the next pod start fails closed
|
||||
- KeyCape's own startup checks (keycape check-config, key-cape c528daa) on the
|
||||
live and the proposed registry, fed on stdin and never written to disk; apply
|
||||
refuses a registry that would stop the single replica at its next start
|
||||
- no Secret value, parser message or Kubernetes error text leaves the process
|
||||
|
||||
Modes: inspect (default), dry-run (server-side), apply. apply requires the
|
||||
case's acceptance record instead of a per-client founder decision id: the case
|
||||
was accepted under an acknowledged type.
|
||||
|
||||
keycape-client.py client-from-case <case.yaml>
|
||||
keycape-client.py inspect --client-file client.yaml [--operation remove]
|
||||
keycape-client.py apply --client-file client.yaml --case-acceptance <record> \\
|
||||
--check-config <keycape binary> --expected-uid <uid> --expected-resource-version <rv>
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import copy
|
||||
import importlib.util
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
ROOT = Path(__file__).resolve().parent
|
||||
|
||||
|
||||
def module(name, filename):
|
||||
spec = importlib.util.spec_from_file_location(name, ROOT / filename)
|
||||
result = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(result)
|
||||
return result
|
||||
|
||||
|
||||
lane = module('portal_rollout', 'portal-client-rollout.py')
|
||||
pin = lane.pin
|
||||
Refused = lane.Refused
|
||||
require = lane.require
|
||||
|
||||
CASE_TYPE = re.compile(r'^oidc-browser-relying-party@1\.\d+$')
|
||||
CLIENT_ID = re.compile(r'^[a-z0-9][a-z0-9-]{1,62}$')
|
||||
# KeyCape refuses '*' and '?' at startup; OAuth 2.0 forbids fragments.
|
||||
REDIRECT = re.compile(r'^https://[^*?#\s,]+$')
|
||||
SCOPES = {'openid', 'profile', 'email', 'groups'}
|
||||
|
||||
|
||||
def env_name(client_id):
|
||||
return 'KEYCAPE_' + client_id.upper().replace('-', '_') + '_CLIENT_SECRET'
|
||||
|
||||
|
||||
def env_secret_name(client_id):
|
||||
return f'keycape-{client_id}-client'
|
||||
|
||||
|
||||
def client_from_params(params):
|
||||
"""The KeyCape client record for a case of oidc-browser-relying-party."""
|
||||
cid = params['client_id']
|
||||
require(CLIENT_ID.fullmatch(cid or '') is not None, 'invalid_client_id')
|
||||
redirects = [r for r in params['redirect_uris'].split(',')]
|
||||
require(redirects and all(REDIRECT.fullmatch(r) for r in redirects), 'redirect_uris_must_be_exact_https')
|
||||
scopes = params['allowed_scopes'].split(',')
|
||||
require(scopes[0] == 'openid' and set(scopes) <= SCOPES and len(set(scopes)) == len(scopes),
|
||||
'allowed_scopes_invalid')
|
||||
# KeyCape has no per-client group gate; the consumer reads groups, and UserInfo
|
||||
# returns them only for the groups scope.
|
||||
require(not params.get('access_groups') or 'groups' in scopes, 'access_groups_require_groups_scope')
|
||||
require(params['client_type'] in ('confidential', 'public'), 'client_type_invalid')
|
||||
# The type derives custody at platform/workloads/keycape/<client_id>; a case cannot choose it.
|
||||
require('secret_ref' not in params, 'secret_ref_is_derived_by_the_type')
|
||||
require(params['mfa_required'] in ('true', 'false'), 'mfa_required_invalid')
|
||||
client = {
|
||||
'clientId': cid,
|
||||
'displayName': params['display_name'],
|
||||
'redirectUris': redirects,
|
||||
'allowedScopes': scopes,
|
||||
'grantTypes': ['authorization_code'],
|
||||
'clientType': params['client_type'],
|
||||
}
|
||||
if params['client_type'] == 'confidential':
|
||||
client['secretRef'] = 'env:' + env_name(cid)
|
||||
if params['mfa_required'] == 'true':
|
||||
client['mfaRequired'] = True
|
||||
return client
|
||||
|
||||
|
||||
def client_from_case(case):
|
||||
spec = case.get('spec') or {}
|
||||
require(CASE_TYPE.fullmatch(str(spec.get('type', ''))) is not None, 'case_type_not_oidc_browser_relying_party')
|
||||
return client_from_params(spec.get('parameters') or {})
|
||||
|
||||
|
||||
def env_binding(client):
|
||||
return {'name': env_name(client['clientId']), 'valueFrom': {
|
||||
'secretKeyRef': {'name': env_secret_name(client['clientId']), 'key': 'client-secret'}}}
|
||||
|
||||
|
||||
def _client_node(raw):
|
||||
root = yaml.compose(raw)
|
||||
node = next(value for key, value in root.value if key.value == 'clients')
|
||||
require(isinstance(node, yaml.nodes.SequenceNode) and not node.flow_style,
|
||||
'block_client_sequence_required')
|
||||
return node
|
||||
|
||||
|
||||
def _line_start(raw, index):
|
||||
start = raw.rfind('\n', 0, index) + 1
|
||||
return start if not raw[start:index].strip() else index
|
||||
|
||||
|
||||
def _inspect(secret, client):
|
||||
raw, config, _ = pin.issuer_document(secret)
|
||||
require(config['authelia'].get('issuer') == pin.VERIFIED_UPSTREAM_ISSUER, 'verified_issuer_pin_required')
|
||||
clients = config.get('clients')
|
||||
require(isinstance(clients, list) and clients, 'client_sequence_required')
|
||||
require(all(isinstance(c, dict) and 'clientId' in c for c in clients), 'client_shape_unexpected')
|
||||
ids = [c['clientId'] for c in clients]
|
||||
require(len(ids) == len(set(ids)), 'duplicate_client_id')
|
||||
if 'secretRef' in client:
|
||||
others = [c for c in clients if c['clientId'] != client['clientId']]
|
||||
require(all(c.get('secretRef') != client['secretRef'] for c in others), 'secret_ref_already_bound')
|
||||
return raw, config, ids
|
||||
|
||||
|
||||
def replacement(secret, client):
|
||||
"""Return (base64 config, changed) with only this client added."""
|
||||
raw, config, ids = _inspect(secret, client)
|
||||
if client['clientId'] in ids:
|
||||
require(config['clients'][ids.index(client['clientId'])] == client,
|
||||
'existing_registration_differs_requires_reconciliation')
|
||||
return secret['data']['config.yaml'], False
|
||||
node = _client_node(raw)
|
||||
index = _line_start(raw, node.end_mark.index)
|
||||
indent = ' ' * node.start_mark.column
|
||||
fragment = yaml.safe_dump([client], sort_keys=False, width=120)
|
||||
fragment = ''.join(indent + line if line.strip() else line for line in fragment.splitlines(True))
|
||||
if index and raw[index - 1] != '\n':
|
||||
fragment = '\n' + fragment
|
||||
updated = raw[:index] + fragment + raw[index:]
|
||||
expected = copy.deepcopy(config)
|
||||
expected['clients'].append(client)
|
||||
require(yaml.load(updated, Loader=pin.UniqueLoader) == expected, 'unrelated_configuration_changed')
|
||||
return base64.b64encode(updated.encode()).decode(), True
|
||||
|
||||
|
||||
def removal(secret, client):
|
||||
"""Rollback: return (base64 config, changed) with only this exact client removed."""
|
||||
raw, config, ids = _inspect(secret, client)
|
||||
if client['clientId'] not in ids:
|
||||
return secret['data']['config.yaml'], False
|
||||
position = ids.index(client['clientId'])
|
||||
require(config['clients'][position] == client, 'existing_registration_differs_requires_reconciliation')
|
||||
require(len(ids) > 1, 'removal_would_empty_client_sequence')
|
||||
node = _client_node(raw)
|
||||
item = node.value[position]
|
||||
start = raw.rfind('\n', 0, item.start_mark.index) + 1
|
||||
require(raw[start:item.start_mark.index].strip() == '-', 'unexpected_item_layout')
|
||||
if position + 1 < len(node.value):
|
||||
end = raw.rfind('\n', 0, node.value[position + 1].start_mark.index) + 1
|
||||
else:
|
||||
end = _line_start(raw, node.end_mark.index)
|
||||
updated = raw[:start] + raw[end:]
|
||||
expected = copy.deepcopy(config)
|
||||
del expected['clients'][position]
|
||||
require(yaml.load(updated, Loader=pin.UniqueLoader) == expected, 'unrelated_configuration_changed')
|
||||
return base64.b64encode(updated.encode()).decode(), True
|
||||
|
||||
|
||||
def deployment_bound(deployment, client):
|
||||
"""Whether the live Deployment carries exactly this client's secret binding."""
|
||||
if 'secretRef' not in client:
|
||||
return None
|
||||
containers = deployment['spec']['template']['spec']['containers']
|
||||
container = next(c for c in containers if c['name'] == 'keycape')
|
||||
matches = [e for e in container.get('env', []) if e.get('name') == env_name(client['clientId'])]
|
||||
require(len(matches) <= 1 and all(e == env_binding(client) for e in matches), 'env_binding_drift')
|
||||
return bool(matches)
|
||||
|
||||
|
||||
def check_config(binary, config_text, env_names, policy_store, *, executable_fd=None):
|
||||
"""KeyCape's startup checks on one registry: names and messages only, no values."""
|
||||
executable = str(Path(binary).resolve()) if executable_fd is None else f'/proc/self/fd/{executable_fd}'
|
||||
command = [executable, 'check-config', '--config', '/dev/stdin', '--env-names', ','.join(env_names)]
|
||||
if policy_store:
|
||||
command.append('--policy-store')
|
||||
result = subprocess.run(command, input=config_text.encode(), capture_output=True, timeout=60, env={},
|
||||
pass_fds=() if executable_fd is None else (executable_fd,))
|
||||
report = json.loads(result.stdout)
|
||||
require(isinstance(report, dict) and isinstance(report.get('valid'), bool)
|
||||
and report['valid'] == (result.returncode == 0), 'check_config_output_shape')
|
||||
return {'valid': report['valid'], 'clients': report.get('clients'),
|
||||
'errors': [str(e) for e in report.get('errors') or []]}
|
||||
|
||||
|
||||
def deployment_env_names(deployment):
|
||||
containers = deployment['spec']['template']['spec']['containers']
|
||||
container = next(c for c in containers if c['name'] == 'keycape')
|
||||
return sorted(e['name'] for e in container.get('env', []))
|
||||
|
||||
|
||||
def run(args, client):
|
||||
kubectl = 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)
|
||||
encoded, changed = (replacement if args.operation == 'add' else removal)(before, client)
|
||||
bound = deployment = None
|
||||
if 'secretRef' in client or args.check_config:
|
||||
deployment = kubectl(['-n', 'sso', 'get', 'deployment', 'keycape', '-o', 'json'])
|
||||
bound = deployment_bound(deployment, client)
|
||||
checks = None
|
||||
if args.check_config:
|
||||
names = deployment_env_names(deployment)
|
||||
policy_store = 'KEYCAPE_POLICY_PATH' in names
|
||||
checker_options = ({'executable_fd': args.check_config_fd}
|
||||
if getattr(args, 'check_config_fd', None) is not None else {})
|
||||
checks = {stage: check_config(args.check_config, base64.b64decode(config).decode(), names, policy_store, **checker_options)
|
||||
for stage, config in (('live', before['data']['config.yaml']), ('proposed', encoded))}
|
||||
receipt = {'schema': 'netkingdom.keycape-client.v1', 'target': 'sso/keycape-config',
|
||||
'before': metadata, 'operation': args.operation, 'client_id': client['clientId'],
|
||||
'client_type': client['clientType'], 'change_needed': changed, 'mode': args.mode,
|
||||
'deployment_env_bound': bound, 'check_config': checks, 'values_emitted': False, 'changed': False}
|
||||
if args.mode == 'inspect':
|
||||
return receipt
|
||||
if args.mode == 'apply':
|
||||
require(bool(args.case_acceptance), 'case_acceptance_record_required')
|
||||
receipt['case_acceptance'] = args.case_acceptance
|
||||
require(checks is not None, 'keycape_check_config_required')
|
||||
require(checks['proposed']['valid'], 'proposed_registry_fails_keycape_check_config')
|
||||
if args.operation == 'add' and bound is not None:
|
||||
require(bound, 'deployment_env_binding_required_first')
|
||||
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):
|
||||
parser = argparse.ArgumentParser(description=__doc__.split('\n', 1)[0])
|
||||
parser.add_argument('mode', nargs='?', default='inspect',
|
||||
choices=['inspect', 'dry-run', 'apply', 'client-from-case'])
|
||||
parser.add_argument('case', nargs='?', help='case file, for client-from-case')
|
||||
parser.add_argument('--client-file', help='KeyCape client record (output of client-from-case)')
|
||||
parser.add_argument('--operation', default='add', choices=['add', 'remove'])
|
||||
parser.add_argument('--expected-uid')
|
||||
parser.add_argument('--expected-resource-version')
|
||||
parser.add_argument('--case-acceptance', help='the case acceptance record; required for apply')
|
||||
parser.add_argument('--check-config', help='a keycape binary with check-config (key-cape c528daa or later); required for apply')
|
||||
args = parser.parse_args(argv)
|
||||
try:
|
||||
if args.mode == 'client-from-case':
|
||||
require(bool(args.case), 'case_file_required')
|
||||
print(yaml.safe_dump(client_from_case(yaml.safe_load(Path(args.case).read_text())), sort_keys=False), end='')
|
||||
return 0
|
||||
require(bool(args.client_file), 'client_file_required')
|
||||
client = yaml.safe_load(Path(args.client_file).read_text())
|
||||
require(isinstance(client, dict) and 'clientId' in client, 'client_file_shape')
|
||||
print(json.dumps(run(args, client), sort_keys=True))
|
||||
return 0
|
||||
except Refused as error:
|
||||
print(json.dumps({'status': 'refused', 'reason': str(error)}))
|
||||
except pin.IssuerPinError as error:
|
||||
print(json.dumps({'status': 'refused', 'reason': str(error)}))
|
||||
except Exception:
|
||||
# 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())
|
||||
|
|
@ -0,0 +1,217 @@
|
|||
"""Unexercised, injection-only attended registration seam for Warden's v1 kernel.
|
||||
|
||||
No CLI, credentials, evidence trust root or live registration is installed here.
|
||||
Owner publication/authentication and parent-session revocation are mandatory
|
||||
injected capabilities. The kernel must independently admit both evidence kinds.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from contextlib import contextmanager
|
||||
import fcntl
|
||||
import os
|
||||
import stat
|
||||
from dataclasses import asdict, replace
|
||||
import json
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
from warden.case_run import Adapter, Descriptor, Invocation, OwnerResult, contained_output, digest, IDENTIFIER, DIGEST
|
||||
from warden.case_receipt_state import Outcome, Refused
|
||||
|
||||
ROOT = Path(__file__).resolve().parent
|
||||
SOURCE_FILES = ('keycape-registration-adapter.py', 'keycape-client.py',
|
||||
'portal-client-rollout.py', 'openbao-client-config.py',
|
||||
'register-user-engine-portal.py')
|
||||
REFERENCE = 'net-kingdom/keycape-registration@v1'
|
||||
|
||||
# Importing the maintained helper performs no tooling operation.
|
||||
_spec = importlib.util.spec_from_file_location('registration_owner_helper', ROOT / 'keycape-client.py')
|
||||
helper = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(helper)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def checker_snapshot(path, expected_digest):
|
||||
"""Execute only verified immutable bytes, independent of later path changes.
|
||||
|
||||
Linux sealed memfd and procfs are required. This is a per-attempt executable
|
||||
snapshot, not native owner storage custody or process/credential isolation.
|
||||
"""
|
||||
if not isinstance(expected_digest, str) or not DIGEST.fullmatch(expected_digest):
|
||||
raise Refused('exact reviewed checker digest required')
|
||||
source_fd = os.open(path, os.O_RDONLY | os.O_CLOEXEC | os.O_NOFOLLOW | os.O_NONBLOCK)
|
||||
with os.fdopen(source_fd, 'rb') as source:
|
||||
metadata = os.fstat(source.fileno())
|
||||
if not stat.S_ISREG(metadata.st_mode):
|
||||
raise Refused('regular checker artifact required')
|
||||
if metadata.st_size > 64 * 1024 * 1024:
|
||||
raise Refused('checker artifact exceeds snapshot limit')
|
||||
data = source.read(64 * 1024 * 1024 + 1)
|
||||
if len(data) > 64 * 1024 * 1024 or digest(data) != expected_digest:
|
||||
raise Refused('check-config binary changed or exceeds snapshot limit')
|
||||
fd = os.memfd_create('keycape-reviewed-checker', os.MFD_CLOEXEC | os.MFD_ALLOW_SEALING)
|
||||
try:
|
||||
with os.fdopen(os.dup(fd), 'wb') as output:
|
||||
output.write(data)
|
||||
os.fchmod(fd, 0o500)
|
||||
seals = fcntl.F_SEAL_WRITE | fcntl.F_SEAL_GROW | fcntl.F_SEAL_SHRINK | fcntl.F_SEAL_SEAL
|
||||
fcntl.fcntl(fd, fcntl.F_ADD_SEALS, seals)
|
||||
if fcntl.fcntl(fd, fcntl.F_GET_SEALS) != seals:
|
||||
raise Refused('immutable checker snapshot required')
|
||||
yield fd
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
def source_digest(check_config_digest):
|
||||
"""Pin adapter plus the complete local helper import closure, in filename order."""
|
||||
if not isinstance(check_config_digest, str) or not DIGEST.fullmatch(check_config_digest):
|
||||
raise Refused('exact reviewed checker digest required')
|
||||
composition = {'files': {name: digest((ROOT / name).read_bytes()) for name in SOURCE_FILES},
|
||||
'check_config_binary': check_config_digest}
|
||||
return digest(json.dumps(composition, sort_keys=True, separators=(',', ':')).encode())
|
||||
|
||||
|
||||
def descriptor(check_config_digest):
|
||||
return Descriptor('warden.case.execution.v1', 'register-client', 'net-kingdom',
|
||||
REFERENCE, source_digest(check_config_digest), 'human', 'key-cape-oidc-login',
|
||||
'client_id', 'change', ('keycape-client',), False,
|
||||
('parent-session',))
|
||||
|
||||
|
||||
def build(*, admitted: Invocation, case_source: bytes, expected_uid: str,
|
||||
expected_resource_version: str, case_acceptance: str,
|
||||
check_config: Path, check_config_digest: str, publish, authenticate,
|
||||
revoke_session):
|
||||
"""Bind one admitted case and observed revision; return a closed kernel Adapter.
|
||||
|
||||
publish(bytes)->identifier durably retains only the closed metadata payload.
|
||||
authenticate(identifier, bytes)->True must verify independent owner custody.
|
||||
revoke_session(invocation)->identifier must revoke this attempt's parent
|
||||
session; Warden independently authenticates it using its own evidence verifier.
|
||||
These callbacks are capabilities, never command strings or case parameters.
|
||||
|
||||
Only a verified creation in this process may be rolled back. Process loss,
|
||||
write/readback ambiguity, publication/revocation failure and source drift
|
||||
require owner reconciliation; none are converted to retry-safe failures.
|
||||
"""
|
||||
reviewed_source = source_digest(check_config_digest)
|
||||
desc = descriptor(check_config_digest)
|
||||
if type(admitted) is not Invocation or not all(
|
||||
isinstance(value, str) and IDENTIFIER.fullmatch(value)
|
||||
for value in (admitted.step, admitted.owner, admitted.target,
|
||||
admitted.actor, admitted.principal, admitted.lane)):
|
||||
raise Refused('bounded invocation required')
|
||||
if desc.adapter_source != reviewed_source:
|
||||
raise Refused('owner source changed during binding')
|
||||
if (admitted.step, admitted.owner, admitted.actor, admitted.lane) != (
|
||||
desc.step, desc.owner, desc.actor, desc.lane):
|
||||
raise Refused('registration invocation boundary differs')
|
||||
if admitted.operation != 'admission' or admitted.attempt_id:
|
||||
raise Refused('expected admission invocation')
|
||||
if type(case_source) is not bytes or len(case_source) > 262144 or digest(case_source) != admitted.pins.case:
|
||||
raise Refused('case source pin differs')
|
||||
# Pins.adapter is the descriptor digest, not the owner source digest.
|
||||
descriptor_bytes = json.dumps(asdict(desc), sort_keys=True, separators=(',', ':')).encode()
|
||||
if admitted.pins.adapter != digest(descriptor_bytes):
|
||||
raise Refused('descriptor pin differs')
|
||||
if not all(isinstance(x, str) and 0 < len(x) <= 192 for x in
|
||||
(expected_uid, expected_resource_version, case_acceptance)):
|
||||
raise Refused('bounded attended binding required')
|
||||
if digest(case_acceptance.encode()) != admitted.pins.acceptance:
|
||||
raise Refused('acceptance evidence binding differs')
|
||||
if not all(callable(x) for x in (publish, authenticate, revoke_session)):
|
||||
raise Refused('owner evidence and revocation capabilities required')
|
||||
with contained_output():
|
||||
case = yaml.safe_load(case_source)
|
||||
client = helper.client_from_case(case)
|
||||
if admitted.target != client['clientId']:
|
||||
raise Refused('case target differs')
|
||||
check_config = Path(check_config)
|
||||
records = {}
|
||||
creation = None
|
||||
|
||||
def binding(inv, operation):
|
||||
if (replace(inv, attempt_id='', operation='admission') != admitted
|
||||
or inv.operation != operation or not IDENTIFIER.fullmatch(inv.attempt_id)):
|
||||
raise Refused('attempt binding differs')
|
||||
if source_digest(check_config_digest) != reviewed_source:
|
||||
raise Refused('owner source changed')
|
||||
|
||||
def execute(inv, operation, created=()):
|
||||
nonlocal creation
|
||||
try:
|
||||
with contained_output():
|
||||
binding(inv, 'run' if operation == 'add' else 'rollback')
|
||||
uid, revision = expected_uid, expected_resource_version
|
||||
if operation == 'remove':
|
||||
if (created != ('keycape-client',) or creation is None
|
||||
or not verify(*creation)):
|
||||
raise Refused('no authenticated case-created client')
|
||||
original_payload = json.loads(records[creation[1].evidence][1])
|
||||
uid, revision = original_payload['after']['uid'], original_payload['after']['resource_version']
|
||||
args = argparse.Namespace(mode='apply', operation=operation,
|
||||
expected_uid=uid, expected_resource_version=revision,
|
||||
case_acceptance=case_acceptance, check_config=str(check_config))
|
||||
with checker_snapshot(check_config, check_config_digest) as checker_fd:
|
||||
args.check_config_fd = checker_fd
|
||||
receipt = helper.run(args, client)
|
||||
# The helper has completed CAS, whole-data readback and preservation.
|
||||
# Never retain check-config diagnostics or arbitrary helper fields.
|
||||
if not (receipt['schema'] == 'netkingdom.keycape-client.v1'
|
||||
and receipt['target'] == 'sso/keycape-config'
|
||||
and receipt['client_id'] == admitted.target
|
||||
and receipt['mode'] == 'apply' and receipt['operation'] == operation
|
||||
and receipt['values_emitted'] is False
|
||||
and type(receipt['changed']) is bool
|
||||
and receipt['unrelated_config_bytes_preserved'] is True
|
||||
and receipt['other_secret_data_unchanged'] is True
|
||||
and receipt['before'] == {'uid': uid, 'resource_version': revision}
|
||||
and receipt['after']['uid'] == uid
|
||||
and receipt['check_config']['proposed']['valid'] is True):
|
||||
raise Refused('owner readback contract differs')
|
||||
after = receipt['after']
|
||||
if (set(after) != {'uid', 'resource_version'} or
|
||||
not all(isinstance(v, str) and 0 < len(v) <= 192 for v in after.values())):
|
||||
raise Refused('owner revision contract differs')
|
||||
changed = receipt['changed']
|
||||
outcome = (Outcome.ROLLED_BACK if operation == 'remove' else
|
||||
Outcome.SUCCESS if changed else Outcome.NO_CHANGE)
|
||||
objects = ('keycape-client',) if operation == 'add' and changed else ()
|
||||
finally:
|
||||
# Even helper failure must end the parent session. Failures propagate
|
||||
# contained to the kernel and become uncertain, never safe to replay.
|
||||
with contained_output():
|
||||
revocation = revoke_session(inv)
|
||||
if not isinstance(revocation, str) or not IDENTIFIER.fullmatch(revocation):
|
||||
raise Refused('session revocation evidence missing')
|
||||
payload = json.dumps({'schema': 'netkingdom.keycape-registration.attempt.v1',
|
||||
'invocation': asdict(inv), 'source': reviewed_source,
|
||||
'outcome': outcome.value, 'created': objects, 'before': receipt['before'],
|
||||
'after': after, 'session_revocation': revocation},
|
||||
sort_keys=True, separators=(',', ':')).encode()
|
||||
with contained_output():
|
||||
evidence = publish(payload)
|
||||
if not isinstance(evidence, str) or not IDENTIFIER.fullmatch(evidence) or evidence in records:
|
||||
raise Refused('owner evidence identifier invalid or reused')
|
||||
result = OwnerResult(outcome, evidence, objects, True, revocation,
|
||||
attempt_id=inv.attempt_id, operation=inv.operation)
|
||||
records[evidence] = (inv, payload, result)
|
||||
if operation == 'add' and changed:
|
||||
creation = (inv, result)
|
||||
elif operation == 'remove':
|
||||
creation = None
|
||||
return result
|
||||
|
||||
def verify(inv, result):
|
||||
record = records.get(result.evidence) if type(result) is OwnerResult else None
|
||||
if record is None or record[0] != inv or record[2] != result:
|
||||
return False
|
||||
with contained_output():
|
||||
return authenticate(result.evidence, record[1]) is True
|
||||
|
||||
return Adapter(REFERENCE, reviewed_source, lambda inv: execute(inv, 'add'),
|
||||
verify, lambda inv, created: execute(inv, 'remove', created))
|
||||
|
|
@ -0,0 +1,304 @@
|
|||
#!/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",
|
||||
],
|
||||
"allowedScopes": ["openid", "profile", "email", "groups"],
|
||||
"grantTypes": ["authorization_code"],
|
||||
"clientType": "public",
|
||||
}
|
||||
|
||||
# bao.coulomb.social was retired on 2026-09-15; its callbacks must not return.
|
||||
RETIRED_OPENBAO_REDIRECT_URIS = (
|
||||
"https://bao.coulomb.social/ui/vault/auth/netkingdom/oidc/callback",
|
||||
"https://bao.coulomb.social/ui/vault/auth/keycape/oidc/callback",
|
||||
)
|
||||
|
||||
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("could not decode private config; diagnostics suppressed") from None
|
||||
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 retired_redirect_errors(config: dict[str, Any]) -> list[str]:
|
||||
clients = config.get("clients") if isinstance(config.get("clients"), list) else []
|
||||
target = next(
|
||||
(client for client in clients if isinstance(client, dict) and client.get("clientId") == OPENBAO_CLIENT["clientId"]),
|
||||
{},
|
||||
)
|
||||
retired = sorted(set(RETIRED_OPENBAO_REDIRECT_URIS) & set(target.get("redirectUris") or []))
|
||||
return [f"{OPENBAO_CLIENT['clientId']}.redirectUris retired: {', '.join(retired)}"] if retired else []
|
||||
|
||||
|
||||
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:
|
||||
raise SystemExit("Retired: raw registry patch output is disabled; use the reviewed owner rollout adapter.")
|
||||
|
||||
|
||||
def verify(config: dict[str, Any]) -> None:
|
||||
errors = client_errors(config, OPENBAO_CLIENT) + client_errors(config, RAPP_QONTO_CLIENT) + retired_redirect_errors(config) + 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))
|
||||
if args.mode == "patch":
|
||||
render_patch({}) # Refuse before reading secret-bearing stdin.
|
||||
try:
|
||||
config = load_config()
|
||||
verify(config)
|
||||
except (ValueError, TypeError, KeyError, AttributeError, yaml.YAMLError):
|
||||
raise SystemExit("KeyCape verification failed; private input diagnostics suppressed.") from None
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -0,0 +1,183 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Founder-admitted node wrapper: private local child-ready pipe -> SSH control stdout.
|
||||
No token read/copy, login, tunnel or approval authentication. Parent must capture
|
||||
stdout over its admitted authenticated SSH peer; never use the silent warden lane.
|
||||
"""
|
||||
import argparse
|
||||
import contextlib
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
from pathlib import Path
|
||||
import signal
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
SCHEMA='key39-private-input-ready/v1'
|
||||
PROPOSAL='KEY-WP-0039-T02/attended-lifecycle-v9-ready-input'
|
||||
TARGET='sso/keycape'
|
||||
MAX_READY=4096
|
||||
MAX_ENVELOPE=65536
|
||||
READY_TOTAL_SECONDS=5
|
||||
|
||||
class Refused(Exception):pass
|
||||
|
||||
def need(ok,code):
|
||||
if not ok:raise Refused(code)
|
||||
|
||||
def finite(value):
|
||||
try:return type(value) in (int,float) and math.isfinite(value)
|
||||
except OverflowError:return False
|
||||
|
||||
def hash_bytes(raw):return hashlib.sha256(raw).hexdigest()
|
||||
|
||||
def hex_pin(value):return isinstance(value,str) and len(value)==64 and all(c in '0123456789abcdef' for c in value)
|
||||
|
||||
@contextlib.contextmanager
|
||||
def deadline(seconds):
|
||||
need(finite(seconds) and 0<seconds<=1200,'wrapper_budget_invalid')
|
||||
need(threading.current_thread() is threading.main_thread()
|
||||
and signal.getitimer(signal.ITIMER_REAL)==(0.0,0.0),'wrapper_timer_required')
|
||||
handler=signal.getsignal(signal.SIGALRM)
|
||||
def expired(*_):raise Refused('wrapper_timeout')
|
||||
signal.signal(signal.SIGALRM,expired);signal.setitimer(signal.ITIMER_REAL,seconds)
|
||||
try:yield
|
||||
finally:signal.setitimer(signal.ITIMER_REAL,0);signal.signal(signal.SIGALRM,handler)
|
||||
|
||||
def decode(raw):
|
||||
def unique(pairs):
|
||||
result={}
|
||||
for key,value in pairs:
|
||||
need(key not in result,'wrapper_duplicate_key');result[key]=value
|
||||
return result
|
||||
try:
|
||||
return json.loads(raw,object_pairs_hook=unique,
|
||||
parse_constant=lambda _:(_ for _ in ()).throw(Refused('wrapper_nonfinite_json')))
|
||||
except Refused:raise
|
||||
except Exception:raise Refused('wrapper_json_invalid') from None
|
||||
|
||||
def pinned_file(path,pin,limit,private=False):
|
||||
p=Path(path);need(p.is_absolute() and p.resolve()==p,'wrapper_absolute_pin_required')
|
||||
need(hex_pin(pin),'wrapper_pin_required')
|
||||
fd=os.open(p,os.O_RDONLY|os.O_NOFOLLOW|os.O_NONBLOCK)
|
||||
try:
|
||||
before=os.fstat(fd)
|
||||
need(stat.S_ISREG(before.st_mode) and 0<before.st_size<=limit,'wrapper_file_invalid')
|
||||
if private:
|
||||
need(before.st_uid==os.getuid() and stat.S_IMODE(before.st_mode)==0o600
|
||||
and before.st_nlink==1,'wrapper_private_envelope_required')
|
||||
raw=os.read(fd,limit+1);after=os.fstat(fd)
|
||||
need(len(raw)<=limit and hash_bytes(raw)==pin,'wrapper_source_changed')
|
||||
need((before.st_dev,before.st_ino,before.st_size,before.st_mtime_ns,before.st_ctime_ns)==
|
||||
(after.st_dev,after.st_ino,after.st_size,after.st_mtime_ns,after.st_ctime_ns),
|
||||
'wrapper_file_changed')
|
||||
return raw
|
||||
finally:os.close(fd)
|
||||
|
||||
def validate_marker(raw,*,nonce,executor_pin,envelope_pin,envelope):
|
||||
need(isinstance(raw,bytes) and 0<len(raw)<=MAX_READY and raw.endswith(b'\n')
|
||||
and raw.count(b'\n')==1,'wrapper_ready_invalid')
|
||||
marker=decode(raw)
|
||||
expected={'schema','session_nonce','proposal_id','target','executor_sha256','envelope_sha256',
|
||||
'bao_health_identity_sha256','valid_until_epoch','receive_budget_seconds'}
|
||||
need(isinstance(marker,dict) and set(marker)==expected,'wrapper_ready_invalid')
|
||||
need(marker['schema']==SCHEMA and marker['session_nonce']==nonce
|
||||
and marker['proposal_id']==PROPOSAL and marker['target']==TARGET
|
||||
and marker['executor_sha256']==executor_pin and marker['envelope_sha256']==envelope_pin
|
||||
and marker['bao_health_identity_sha256']==envelope['bao_health_identity_sha256']
|
||||
and marker['valid_until_epoch']==envelope['valid_until_epoch'], 'wrapper_ready_binding_changed')
|
||||
need(finite(marker['valid_until_epoch']) and time.time()<marker['valid_until_epoch']
|
||||
and finite(marker['receive_budget_seconds']) and 0<marker['receive_budget_seconds']<=15,
|
||||
'wrapper_ready_window_expired')
|
||||
return marker
|
||||
|
||||
def read_ready(fd):
|
||||
raw=b''
|
||||
while len(raw)<=MAX_READY:
|
||||
chunk=os.read(fd,MAX_READY+1-len(raw))
|
||||
if not chunk:break
|
||||
raw+=chunk
|
||||
need(len(raw)<=MAX_READY,'wrapper_ready_oversized')
|
||||
return raw
|
||||
|
||||
def forward_ready(raw):
|
||||
# Wrapper stdout is a captured control pipe. Never child stdout or token data.
|
||||
current=os.fstat(1)
|
||||
need(stat.S_ISFIFO(current.st_mode),'wrapper_private_control_pipe_required')
|
||||
need((current.st_dev,current.st_ino)!=(os.fstat(0).st_dev,os.fstat(0).st_ino),
|
||||
'wrapper_control_input_alias_refused')
|
||||
offset=0
|
||||
while offset<len(raw):
|
||||
written=os.write(1,raw[offset:]);need(type(written) is int and written>0,'wrapper_control_write_failed')
|
||||
offset+=written
|
||||
|
||||
def run(args):
|
||||
proc=None;read_fd=None;write_fd=None
|
||||
try:
|
||||
need(hex_pin(args.session_nonce),'wrapper_nonce_required')
|
||||
with deadline(READY_TOTAL_SECONDS):
|
||||
child=pinned_file(args.child,args.child_sha256,256*1024)
|
||||
envelope_raw=pinned_file(args.envelope,args.envelope_sha256,MAX_ENVELOPE,private=True)
|
||||
envelope=decode(envelope_raw)
|
||||
need(envelope.get('proposal_id')==PROPOSAL and envelope.get('target')==TARGET
|
||||
and envelope.get('disposition')=='approved' and envelope.get('founder_decision_id'),
|
||||
'wrapper_exact_owner_envelope_required')
|
||||
need(envelope.get('source_hashes',{}).get(str(Path(args.child)))==args.child_sha256
|
||||
and envelope.get('source_hashes',{}).get(str(Path(__file__).resolve()))==hash_bytes(Path(__file__).read_bytes()),
|
||||
'wrapper_child_and_wrapper_pins_required')
|
||||
expiry=envelope['valid_until_epoch'];maximum=envelope['maximum_session_seconds']
|
||||
need(finite(expiry) and type(maximum) is int and 420<=maximum<=900
|
||||
and 0<expiry-time.time()<=maximum,'wrapper_action_window_invalid')
|
||||
absolute_end=time.monotonic()+expiry-time.time()+300
|
||||
read_fd,write_fd=os.pipe()
|
||||
proc=subprocess.Popen([sys.executable,args.child,'execute','--envelope',args.envelope,
|
||||
'--receipt',args.receipt,'--ready-fd',str(write_fd),'--session-nonce',args.session_nonce,
|
||||
'--ready-envelope-sha256',args.envelope_sha256],stdin=sys.stdin.buffer,
|
||||
stdout=subprocess.PIPE,stderr=subprocess.PIPE,pass_fds=(write_fd,),close_fds=True)
|
||||
os.close(write_fd);write_fd=None
|
||||
raw=read_ready(read_fd);os.close(read_fd);read_fd=None
|
||||
validate_marker(raw,nonce=args.session_nonce,executor_pin=args.child_sha256,
|
||||
envelope_pin=args.envelope_sha256,envelope=envelope)
|
||||
forward_ready(raw)
|
||||
remaining=absolute_end-time.monotonic();need(remaining>0,'wrapper_session_window_expired')
|
||||
with deadline(remaining):
|
||||
rc=proc.wait(timeout=remaining)
|
||||
# Bounded captures: even accidental child output is never forwarded.
|
||||
unexpected=proc.stdout.read(MAX_READY+1) or proc.stderr.read(MAX_READY+1)
|
||||
need(not unexpected,'wrapper_child_output_refused')
|
||||
return 0 if rc==0 else 1
|
||||
except BaseException:
|
||||
return 1
|
||||
finally:
|
||||
for fd in (read_fd,write_fd):
|
||||
if fd is not None:
|
||||
try:os.close(fd)
|
||||
except OSError:pass
|
||||
if proc is not None:
|
||||
if proc.poll() is None:
|
||||
# Unknown/partial native outcomes remain owner recovery, never retry.
|
||||
try:
|
||||
with deadline(5):
|
||||
proc.terminate();proc.wait(timeout=5)
|
||||
except Exception:
|
||||
try:
|
||||
with deadline(2):
|
||||
proc.kill();proc.wait(timeout=2)
|
||||
except Exception:pass
|
||||
for stream in (proc.stdout,proc.stderr):
|
||||
if stream is not None:
|
||||
try:
|
||||
with deadline(0.02):stream.close()
|
||||
except Exception:pass
|
||||
|
||||
def main():
|
||||
parser=argparse.ArgumentParser(description=__doc__)
|
||||
for name in ('child','child-sha256','envelope','envelope-sha256','receipt','session-nonce'):
|
||||
parser.add_argument('--'+name,required=True)
|
||||
return run(parser.parse_args())
|
||||
|
||||
if __name__=='__main__':raise SystemExit(main())
|
||||
|
|
@ -0,0 +1,860 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Founder-attended KEY39 lifecycle. Silent; private values never leave memory/K8s.
|
||||
|
||||
inspect is metadata-only (no Secret read). execute requires an exact approved
|
||||
envelope, source/runtime pins and founder-held private tokens on stdin. No login,
|
||||
custody fetch, production credential, role or policy mutation is implemented.
|
||||
"""
|
||||
import argparse
|
||||
import base64
|
||||
import copy
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import math
|
||||
import fcntl
|
||||
import stat
|
||||
import os
|
||||
from pathlib import Path
|
||||
import secrets
|
||||
import signal
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from contextlib import contextmanager
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
import yaml
|
||||
|
||||
CLUSTER = 'a553c742-0115-43d4-99a4-a5ca56fe0786'
|
||||
TARGET = 'sso/keycape'
|
||||
PROPOSAL = 'KEY-WP-0039-T02/attended-lifecycle-v9-ready-input'
|
||||
IDS = ['openbao-probe-' + c for c in ['aud', 'sub', 'tenant', 'roles', 'scope', 'expiry']]
|
||||
ENVS = ['KEYCAPE_' + c.upper().replace('-', '_') + '_CLIENT_SECRET' for c in IDS]
|
||||
TEMP = 'keycape-openbao-negative-key39'
|
||||
BAO_ORIGINS = {'http://127.0.0.1:18200', 'http://openbao.openbao.svc:8200'}
|
||||
REQUEST_DEADLINE = None
|
||||
BUSINESS_MONOTONIC_DEADLINE = None
|
||||
CLEANUP_MONOTONIC_DEADLINE = None
|
||||
CLEANING = False
|
||||
MAX_RESPONSE = 256 * 1024
|
||||
HTTP_TOTAL_SECONDS = 15
|
||||
CLEANUP_TOTAL_SECONDS = 300
|
||||
HTTP_ABORT_SECONDS = 0.02
|
||||
PRIVATE_INPUT_MAX_BYTES = 65536
|
||||
PRIVATE_TOKEN_MAX_BYTES = 16384
|
||||
PRIVATE_INPUT_TOTAL_SECONDS = 15
|
||||
READY_SCHEMA = 'key39-private-input-ready/v1'
|
||||
READY_MAX_BYTES = 4096
|
||||
|
||||
|
||||
class Refused(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def require(ok, reason):
|
||||
if not ok:
|
||||
raise Refused(reason)
|
||||
|
||||
|
||||
def digest(data):
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
def canonical(value):
|
||||
return digest(json.dumps(value, sort_keys=True, separators=(',', ':')).encode())
|
||||
|
||||
|
||||
def finite_number(value):
|
||||
try:
|
||||
return type(value) in (int, float) and math.isfinite(value)
|
||||
except OverflowError:
|
||||
return False
|
||||
|
||||
|
||||
def read_json(raw):
|
||||
def reject_constant(_):
|
||||
raise Refused('nonfinite_json_constant')
|
||||
return json.loads(raw, parse_constant=reject_constant)
|
||||
|
||||
|
||||
def begin_cleanup():
|
||||
global CLEANUP_MONOTONIC_DEADLINE
|
||||
if CLEANUP_MONOTONIC_DEADLINE is None:
|
||||
CLEANUP_MONOTONIC_DEADLINE = time.monotonic() + CLEANUP_TOTAL_SECONDS
|
||||
|
||||
|
||||
def check_deadline():
|
||||
if CLEANING:
|
||||
require(CLEANUP_MONOTONIC_DEADLINE is not None
|
||||
and time.monotonic() < CLEANUP_MONOTONIC_DEADLINE, 'cleanup_budget_expired')
|
||||
return
|
||||
if REQUEST_DEADLINE is not None and not CLEANING:
|
||||
require(finite_number(REQUEST_DEADLINE), 'approved_window_invalid')
|
||||
require(time.time() < REQUEST_DEADLINE, 'approved_window_expired')
|
||||
if BUSINESS_MONOTONIC_DEADLINE is not None:
|
||||
require(time.monotonic() < BUSINESS_MONOTONIC_DEADLINE, 'session_business_budget_expired')
|
||||
|
||||
|
||||
def request_budget(cap, cleanup=False):
|
||||
require(finite_number(cap) and 0 < cap <= 30, 'request_timeout_invalid')
|
||||
budget = float(cap)
|
||||
if cleanup or CLEANING:
|
||||
if CLEANUP_MONOTONIC_DEADLINE is not None:
|
||||
budget = min(budget, CLEANUP_MONOTONIC_DEADLINE - time.monotonic())
|
||||
else:
|
||||
check_deadline()
|
||||
if REQUEST_DEADLINE is not None:
|
||||
budget = min(budget, REQUEST_DEADLINE - time.time())
|
||||
if BUSINESS_MONOTONIC_DEADLINE is not None:
|
||||
budget = min(budget, BUSINESS_MONOTONIC_DEADLINE - time.monotonic())
|
||||
require(budget > 0 and math.isfinite(budget), 'request_window_exhausted')
|
||||
return budget
|
||||
|
||||
|
||||
@contextmanager
|
||||
def total_timeout(seconds):
|
||||
"""Linux main-thread hard elapsed bound, including blocking headers/body reads."""
|
||||
require(finite_number(seconds) and 0 < seconds <= 30, 'request_timeout_invalid')
|
||||
require(threading.current_thread() is threading.main_thread()
|
||||
and hasattr(signal, 'setitimer'), 'main_thread_linux_timer_required')
|
||||
previous_timer = signal.getitimer(signal.ITIMER_REAL)
|
||||
require(previous_timer == (0.0, 0.0), 'existing_alarm_timer_refused')
|
||||
previous_handler = signal.getsignal(signal.SIGALRM)
|
||||
def expired(*_):
|
||||
raise Refused('request_total_timeout')
|
||||
started = time.monotonic()
|
||||
signal.signal(signal.SIGALRM, expired)
|
||||
signal.setitimer(signal.ITIMER_REAL, seconds)
|
||||
try:
|
||||
yield
|
||||
require(time.monotonic() - started <= seconds, 'request_total_timeout')
|
||||
finally:
|
||||
signal.setitimer(signal.ITIMER_REAL, 0)
|
||||
signal.signal(signal.SIGALRM, previous_handler)
|
||||
|
||||
|
||||
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||
def redirect_request(self, *args, **kwargs):
|
||||
return None
|
||||
|
||||
|
||||
class SessionTransport:
|
||||
"""Fixed origins, no ambient proxy, bounded reads, same guards in cleanup."""
|
||||
def __init__(self, bao_origin, receipt=None):
|
||||
require(bao_origin in BAO_ORIGINS, 'bao_origin_invalid')
|
||||
self.bao_origin = bao_origin
|
||||
self.receipt = receipt if receipt is not None else {}
|
||||
self.opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), NoRedirect())
|
||||
|
||||
def request(self, url, *, data=None, headers=None, method='POST', timeout=15):
|
||||
allowed = {'https://kc.coulomb.social/token',
|
||||
'https://kc.coulomb.social/.well-known/openid-configuration',
|
||||
self.bao_origin + '/v1/auth/keycape-services/login',
|
||||
self.bao_origin + '/v1/auth/token/revoke-self',
|
||||
self.bao_origin + '/v1/sys/health'}
|
||||
require(url in allowed, 'http_target_not_reviewed')
|
||||
require(method == ('GET' if url.endswith(('/.well-known/openid-configuration', '/v1/sys/health')) else 'POST'),
|
||||
'http_method_not_reviewed')
|
||||
cleanup = url.endswith('/v1/auth/token/revoke-self')
|
||||
budget = request_budget(min(timeout, HTTP_TOTAL_SECONDS), cleanup=cleanup)
|
||||
total_deadline = time.monotonic() + budget
|
||||
req = urllib.request.Request(url, data=data, headers=headers or {}, method=method)
|
||||
response, status, raw = None, None, None
|
||||
try:
|
||||
with total_timeout(budget):
|
||||
try:
|
||||
response = self.opener.open(req, timeout=budget)
|
||||
except urllib.error.HTTPError as error:
|
||||
response = error
|
||||
except (urllib.error.URLError, OSError):
|
||||
if url == self.bao_origin + '/v1/auth/keycape-services/login':
|
||||
self.receipt['openbao_login_outcome_unknown'] = True
|
||||
return None, b''
|
||||
status = response.getcode()
|
||||
raw = response.read(MAX_RESPONSE + 1)
|
||||
require(len(raw) <= MAX_RESPONSE, 'http_response_oversized')
|
||||
response.close()
|
||||
response = None
|
||||
if not cleanup:
|
||||
check_deadline()
|
||||
return status, raw
|
||||
except Refused:
|
||||
if url == self.bao_origin + '/v1/auth/keycape-services/login' and status in (None, 200):
|
||||
# A late response cannot pass; preserve acquired-token cleanup when available.
|
||||
if raw is not None and status == 200:
|
||||
self.revoke_late_session(raw)
|
||||
else:
|
||||
self.receipt['openbao_login_outcome_unknown'] = True
|
||||
raise
|
||||
except (urllib.error.URLError, OSError):
|
||||
if url == self.bao_origin + '/v1/auth/keycape-services/login':
|
||||
if raw is not None and status == 200:
|
||||
self.revoke_late_session(raw)
|
||||
else:
|
||||
self.receipt['openbao_login_outcome_unknown'] = True
|
||||
return None, b''
|
||||
except Exception:
|
||||
if url == self.bao_origin + '/v1/auth/keycape-services/login':
|
||||
if raw is not None and status == 200:
|
||||
self.revoke_late_session(raw)
|
||||
else:
|
||||
self.receipt['openbao_login_outcome_unknown'] = True
|
||||
raise Refused('http_response_failed') from None
|
||||
finally:
|
||||
if response is not None:
|
||||
self.abort_response(response, total_deadline)
|
||||
|
||||
def abort_response(self, response, original_deadline):
|
||||
"""One bounded exceptional close; never retry or reset the cleanup clock."""
|
||||
self.receipt['http_abort_close_attempted'] = False
|
||||
self.receipt['http_abort_close_succeeded'] = False
|
||||
begin_cleanup()
|
||||
try:
|
||||
cap = request_budget(HTTP_ABORT_SECONDS, cleanup=True)
|
||||
remaining = original_deadline - time.monotonic()
|
||||
if remaining > 0:
|
||||
cap = min(cap, remaining)
|
||||
self.receipt['http_abort_close_attempted'] = True
|
||||
with total_timeout(cap):
|
||||
response.close()
|
||||
self.receipt['http_abort_close_succeeded'] = True
|
||||
except Exception:
|
||||
self.receipt['http_response_abort_unconfirmed'] = True
|
||||
raise Refused('http_response_abort_unconfirmed') from None
|
||||
|
||||
def revoke_late_session(self, raw):
|
||||
self.receipt['late_acceptance_revoke_attempted'] = False
|
||||
self.receipt['late_acceptance_revoke_succeeded'] = False
|
||||
try:
|
||||
token = read_json(raw)['auth']['client_token']
|
||||
require(isinstance(token, str) and token, 'late_session_response_invalid')
|
||||
except (KeyError, TypeError, ValueError, Refused):
|
||||
self.receipt['openbao_login_outcome_unknown'] = True
|
||||
return
|
||||
begin_cleanup()
|
||||
self.receipt['late_acceptance_revoke_attempted'] = True
|
||||
try:
|
||||
status, _ = self.post(self.bao_origin + '/v1/auth/token/revoke-self',
|
||||
body={}, headers={'X-Vault-Token': token})
|
||||
self.receipt['late_acceptance_revoke_succeeded'] = status == 204
|
||||
except Exception:
|
||||
self.receipt['late_acceptance_revoke_succeeded'] = False
|
||||
finally:
|
||||
del token
|
||||
|
||||
def post(self, url, form=None, body=None, headers=None, timeout=15):
|
||||
headers = dict(headers or {})
|
||||
if form is not None:
|
||||
data = urllib.parse.urlencode(form).encode()
|
||||
headers['Content-Type'] = 'application/x-www-form-urlencoded'
|
||||
else:
|
||||
data = json.dumps(body or {}).encode()
|
||||
headers['Content-Type'] = 'application/json'
|
||||
return self.request(url, data=data, headers=headers, timeout=timeout)
|
||||
|
||||
def health_identity(self):
|
||||
# Public endpoint only: no token/header, login, Secret or route creation.
|
||||
status, raw = self.request(self.bao_origin + '/v1/sys/health', method='GET')
|
||||
require(status == 200, 'bao_public_health_unavailable')
|
||||
try:
|
||||
body = read_json(raw)
|
||||
require(isinstance(body, dict) and body.get('initialized') is True
|
||||
and body.get('sealed') is False and body.get('standby') is False
|
||||
and body.get('performance_standby', False) is False,
|
||||
'bao_public_health_not_active')
|
||||
cluster_id = body.get('cluster_id')
|
||||
require(isinstance(cluster_id, str) and 0 < len(cluster_id) <= 128,
|
||||
'bao_public_health_identity_required')
|
||||
return digest(cluster_id.encode())
|
||||
except (TypeError, ValueError):
|
||||
raise Refused('bao_public_health_invalid') from None
|
||||
|
||||
def discovery(self):
|
||||
status, raw = self.request('https://kc.coulomb.social/.well-known/openid-configuration', method='GET')
|
||||
require(status == 200, 'discovery_failed')
|
||||
return json.loads(raw)
|
||||
|
||||
|
||||
def k(arguments, payload=None):
|
||||
# Execute on railiance01 in the founder's session, never workstation defaults.
|
||||
check_deadline()
|
||||
budget = request_budget(30)
|
||||
with total_timeout(budget):
|
||||
result = subprocess.run(['kubectl', '--kubeconfig=/etc/rancher/k3s/k3s.yaml',
|
||||
'--request-timeout=20s', *arguments],
|
||||
input=None if payload is None else json.dumps(payload),
|
||||
text=True, capture_output=True, timeout=budget)
|
||||
check_deadline()
|
||||
require(result.returncode == 0, 'kubernetes_operation_failed')
|
||||
return json.loads(result.stdout) if result.stdout.strip() else None
|
||||
|
||||
|
||||
def get(kind, name):
|
||||
return k(['-n', 'sso', 'get', kind, name, '-o', 'json'])
|
||||
|
||||
|
||||
def meta(obj):
|
||||
return {key: obj['metadata'][key] for key in ['uid', 'resourceVersion']}
|
||||
|
||||
|
||||
def patch(kind, obj, path, value, dry=False):
|
||||
ops = [{'op': 'test', 'path': '/metadata/' + key, 'value': val}
|
||||
for key, val in meta(obj).items()]
|
||||
ops.append({'op': 'replace', 'path': path, 'value': value})
|
||||
args = ['-n', 'sso', 'patch', kind, obj['metadata']['name'], '--type=json',
|
||||
'--patch-file=/dev/stdin', '-o', 'json']
|
||||
if dry:
|
||||
args += ['--dry-run=server']
|
||||
return k(args, ops)
|
||||
|
||||
|
||||
def registrations():
|
||||
reference = {'displayName': 'Temporary KEY39 negative probe',
|
||||
'allowedScopes': ['openbao:login'], 'grantTypes': ['client_credentials'],
|
||||
'clientType': 'confidential', 'serviceSubject': 'service:secrets-engine',
|
||||
'tenant': 'tenant:platform', 'roles': ['secrets-engine'], 'tokenLifetime': '1m'}
|
||||
result = []
|
||||
for index, client_id in enumerate(IDS):
|
||||
c = dict(reference, clientId=client_id, secretRef='env:' + ENVS[index])
|
||||
if index:
|
||||
c['audience'] = 'secrets-engine-openbao'
|
||||
if index == 1:
|
||||
c['serviceSubject'] = 'service:openbao-probe'
|
||||
if index == 2:
|
||||
c['tenant'] = 'tenant:openbao-probe'
|
||||
if index == 3:
|
||||
c['roles'] = ['openbao-probe']
|
||||
if index == 4:
|
||||
c['allowedScopes'] = ['openbao:probe']
|
||||
result.append(c)
|
||||
return result
|
||||
|
||||
|
||||
class UniqueLoader(yaml.SafeLoader):
|
||||
pass
|
||||
|
||||
|
||||
def unique(loader, node):
|
||||
result = {}
|
||||
for key_node, value_node in node.value:
|
||||
key = loader.construct_object(key_node)
|
||||
require(key not in result, 'duplicate_yaml_key')
|
||||
result[key] = loader.construct_object(value_node)
|
||||
return result
|
||||
|
||||
|
||||
UniqueLoader.add_constructor(yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, unique)
|
||||
|
||||
|
||||
def candidate_config(raw):
|
||||
config = yaml.load(raw, Loader=UniqueLoader)
|
||||
clients = config['clients']
|
||||
require(isinstance(clients, list) and clients, 'clients_required')
|
||||
ids = [c['clientId'] for c in clients]
|
||||
require(len(ids) == len(set(ids)) and not set(ids).intersection(IDS), 'existing_probe_or_duplicate')
|
||||
require(not {c.get('secretRef') for c in clients}.intersection('env:' + e for e in ENVS),
|
||||
'probe_secretref_collision')
|
||||
production = [c for c in clients if c['clientId'] == 'secrets-engine-openbao']
|
||||
require(len(production) == 1, 'production_client_missing')
|
||||
expected = registrations()[-1]
|
||||
for field in ['allowedScopes', 'grantTypes', 'clientType', 'serviceSubject', 'tenant', 'roles']:
|
||||
require(production[0].get(field) == expected[field], 'production_claim_contract_drift')
|
||||
require(production[0].get('audience', production[0]['clientId']) == 'secrets-engine-openbao',
|
||||
'production_audience_drift')
|
||||
nodes = yaml.compose(raw)
|
||||
node = next(v for key, v in nodes.value if key.value == 'clients')
|
||||
require(isinstance(node, yaml.nodes.SequenceNode) and not node.flow_style,
|
||||
'block_client_sequence_required')
|
||||
index = node.end_mark.index
|
||||
start = raw.rfind('\n', 0, index) + 1
|
||||
if not raw[start:index].strip():
|
||||
index = start
|
||||
fragment = yaml.safe_dump(registrations(), sort_keys=False)
|
||||
fragment = ''.join(' ' * node.start_mark.column + line if line.strip() else line
|
||||
for line in fragment.splitlines(True))
|
||||
if index and raw[index - 1] != '\n':
|
||||
fragment = '\n' + fragment
|
||||
updated = raw[:index] + fragment + raw[index:]
|
||||
intended = copy.deepcopy(config)
|
||||
intended['clients'].extend(registrations())
|
||||
require(yaml.load(updated, Loader=UniqueLoader) == intended, 'unrelated_config_changed')
|
||||
return updated
|
||||
|
||||
|
||||
def candidate_spec(spec):
|
||||
result = copy.deepcopy(spec)
|
||||
require(result.get('replicas', 1) == 1 and result['strategy']['type'] == 'Recreate',
|
||||
'single_replica_recreate_required')
|
||||
container = next(c for c in result['template']['spec']['containers'] if c['name'] == 'keycape')
|
||||
env = container['env']
|
||||
require(not {e['name'] for e in env}.intersection(ENVS), 'probe_env_collision')
|
||||
env.extend({'name': e, 'valueFrom': {'secretKeyRef': {'name': TEMP, 'key': c}}}
|
||||
for c, e in zip(IDS, ENVS))
|
||||
return result
|
||||
|
||||
|
||||
def ready(spec, timeout=150):
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
dep = get('deployment', 'keycape')
|
||||
require(dep['spec'] == spec, 'deployment_drift_during_readiness')
|
||||
status = dep.get('status', {})
|
||||
if (status.get('observedGeneration') == dep['metadata']['generation']
|
||||
and all(status.get(key) == 1 for key in
|
||||
['updatedReplicas', 'readyReplicas', 'availableReplicas', 'replicas'])):
|
||||
return True
|
||||
time.sleep(3)
|
||||
raise Refused('readiness_timeout')
|
||||
|
||||
|
||||
def verify_sources(envelope, recovery=False):
|
||||
require(envelope.get('proposal_id') == PROPOSAL and envelope.get('target') == TARGET,
|
||||
'proposal_target_mismatch')
|
||||
require(envelope.get('disposition') == ('recovery-approved' if recovery else 'approved')
|
||||
and envelope.get('founder_decision_id'),
|
||||
'founder_exact_go_required')
|
||||
require(envelope.get('source_hashes') and
|
||||
str(Path(__file__).resolve()) in envelope['source_hashes'], 'executor_pin_required')
|
||||
for filename, expected in envelope['source_hashes'].items():
|
||||
require(digest(Path(filename).read_bytes()) == expected, 'reviewed_source_changed')
|
||||
required = ['net-kingdom', 'key-cape', 'railiance-platform']
|
||||
require(all(envelope.get('owner_acknowledgments', {}).get(owner) for owner in required),
|
||||
'owner_acknowledgments_required')
|
||||
expiry = envelope['valid_until_epoch']
|
||||
maximum = envelope['maximum_session_seconds']
|
||||
require(finite_number(expiry) and type(maximum) is int and
|
||||
420 <= maximum <= 900 and 0 < expiry - time.time() <= maximum,
|
||||
'attended_window_invalid')
|
||||
if recovery:
|
||||
return None
|
||||
role = envelope['role_observation']
|
||||
require(role['mount'] == 'keycape-services' and role['role'] == 'secrets-engine-openbao'
|
||||
and role['issuer'] == 'https://kc.coulomb.social' and role['algorithm'] == 'RS256'
|
||||
and role['bindings_exact'] is True and role['policy_exact'] is True,
|
||||
'role_owner_observation_required')
|
||||
leeway = role['raw_expiration_leeway']
|
||||
require(type(leeway) is int and -1 <= leeway <= 150, 'leeway_outside_session_bound')
|
||||
effective = 0 if leeway == -1 else leeway or 150
|
||||
require(role['effective_expiration_leeway'] == effective, 'leeway_normalization_mismatch')
|
||||
observed = role['observed_at_epoch']
|
||||
require(finite_number(observed) and 0 <= time.time() - observed <= 300,
|
||||
'role_observation_stale_or_future')
|
||||
require(envelope['bao_addr'] in BAO_ORIGINS, 'bao_origin_invalid')
|
||||
return effective
|
||||
|
||||
|
||||
def load_probe(path):
|
||||
spec = importlib.util.spec_from_file_location('key39_reviewed_probe', path)
|
||||
result = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(result)
|
||||
return result
|
||||
|
||||
|
||||
def removed_client_denied(status, raw):
|
||||
try:
|
||||
body = json.loads(raw)
|
||||
return status == 401 and body.get('error') == 'invalid_profile_usage' and body.get('feature') == 'client_id'
|
||||
except (ValueError, AttributeError, TypeError):
|
||||
return False
|
||||
|
||||
|
||||
def recovered_config(raw):
|
||||
config = yaml.load(raw, Loader=UniqueLoader)
|
||||
clients = config['clients']
|
||||
ids = [c['clientId'] for c in clients]
|
||||
present = set(ids).intersection(IDS)
|
||||
require(len(ids) == len(set(ids)) and present in [set(), set(IDS)], 'partial_probe_registry')
|
||||
if not present:
|
||||
return raw
|
||||
require(clients[-6:] == registrations(), 'recovery_registration_drift')
|
||||
node = next(v for key, v in yaml.compose(raw).value if key.value == 'clients')
|
||||
first = node.value[-6].start_mark.index
|
||||
start = raw.rfind('\n', 0, first) + 1
|
||||
end = node.end_mark.index
|
||||
line_start = raw.rfind('\n', 0, end) + 1
|
||||
if not raw[line_start:end].strip():
|
||||
end = line_start
|
||||
restored = raw[:start] + raw[end:]
|
||||
require(candidate_config(restored) == raw, 'recovery_byte_roundtrip_failed')
|
||||
return restored
|
||||
|
||||
|
||||
def recovered_spec(spec):
|
||||
restored = copy.deepcopy(spec)
|
||||
container = next(c for c in restored['template']['spec']['containers'] if c['name'] == 'keycape')
|
||||
env = container['env']
|
||||
expected = [{'name': e, 'valueFrom': {'secretKeyRef': {'name': TEMP, 'key': c}}}
|
||||
for c, e in zip(IDS, ENVS)]
|
||||
matches = [e for e in env if e['name'] in ENVS]
|
||||
require(matches in [[], expected], 'partial_or_drifted_probe_env')
|
||||
container['env'] = [e for e in env if e['name'] not in ENVS]
|
||||
return restored
|
||||
|
||||
|
||||
def delete_temporary(created):
|
||||
# The exact raw API delete needs the same aggregate/per-request guards as patches.
|
||||
k(['delete', '--raw=/api/v1/namespaces/sso/secrets/' + TEMP, '-f', '/dev/stdin'],
|
||||
{'apiVersion': 'v1', 'kind': 'DeleteOptions', 'preconditions': meta(created)})
|
||||
require(k(['-n', 'sso', 'get', 'secret', TEMP, '--ignore-not-found', '-o', 'json']) is None,
|
||||
'temporary_secret_still_present')
|
||||
|
||||
|
||||
def verify_bao_route(transport, envelope, receipt):
|
||||
expected = envelope.get('bao_health_identity_sha256')
|
||||
require(isinstance(expected, str) and len(expected) == 64
|
||||
and all(c in '0123456789abcdef' for c in expected),
|
||||
'approved_bao_health_identity_required')
|
||||
require(transport.health_identity() == expected, 'bao_public_health_identity_changed')
|
||||
receipt['bao_public_route_verified'] = True
|
||||
|
||||
|
||||
def recover(envelope, receipt):
|
||||
global REQUEST_DEADLINE, BUSINESS_MONOTONIC_DEADLINE, CLEANUP_MONOTONIC_DEADLINE, CLEANING
|
||||
verify_sources(envelope, recovery=True)
|
||||
# Separately approved recovery obligations are cleanup, never business writes.
|
||||
REQUEST_DEADLINE = None
|
||||
BUSINESS_MONOTONIC_DEADLINE = None
|
||||
CLEANUP_MONOTONIC_DEADLINE = None
|
||||
begin_cleanup()
|
||||
CLEANING = True
|
||||
transport = SessionTransport(envelope['bao_addr'], receipt=receipt)
|
||||
verify_bao_route(transport, envelope, receipt)
|
||||
require(k(['get', 'namespace', 'kube-system', '-o', 'json'])['metadata']['uid'] == CLUSTER,
|
||||
'cluster_identity_changed')
|
||||
config = get('secret', 'keycape-config')
|
||||
dep = get('deployment', 'keycape')
|
||||
raw = base64.b64decode(config['data']['config.yaml'], validate=True).decode()
|
||||
restored = recovered_config(raw)
|
||||
spec = recovered_spec(dep['spec'])
|
||||
require(config['metadata']['uid'] == envelope['config_metadata']['uid']
|
||||
and dep['metadata']['uid'] == envelope['deployment_metadata']['uid']
|
||||
and digest(restored.encode()) == envelope['registry_sha256']
|
||||
and canonical(spec) == envelope['deployment_spec_sha256'], 'recovery_baseline_drift')
|
||||
temp = get('secret', TEMP)
|
||||
require(set(temp.get('data', {})) == set(IDS)
|
||||
and temp['metadata']['uid'] == envelope['temporary_secret_uid'], 'recovery_custody_identity_required')
|
||||
values = {c: base64.b64decode(temp['data'][c], validate=True).decode() for c in IDS}
|
||||
if raw != restored:
|
||||
patch('secret', config, '/data/config.yaml', base64.b64encode(restored.encode()).decode())
|
||||
if dep['spec'] != spec:
|
||||
patch('deployment', dep, '/spec', spec)
|
||||
ready(spec)
|
||||
after = get('secret', 'keycape-config')
|
||||
require(after['data'] == dict(config['data'], **{'config.yaml': base64.b64encode(restored.encode()).decode()}),
|
||||
'recovery_readback_failed')
|
||||
require(envelope['runner_path'] in envelope['source_hashes'], 'runner_pin_required')
|
||||
probe = load_probe(envelope['runner_path'])
|
||||
for client, value in values.items():
|
||||
basic = base64.b64encode((client + ':' + value).encode()).decode()
|
||||
status, body = transport.post('https://kc.coulomb.social/token',
|
||||
form={'grant_type': 'client_credentials', 'scope': 'openbao:login'},
|
||||
headers={'Authorization': 'Basic ' + basic})
|
||||
require(removed_client_denied(status, body), 'removed_client_denial_unconfirmed')
|
||||
observed = get('secret', TEMP)
|
||||
require(observed['data'] == temp['data'] and observed['metadata']['uid'] == temp['metadata']['uid'],
|
||||
'recovery_temporary_secret_drift')
|
||||
delete_temporary(observed)
|
||||
receipt.update(cleanup_verified=True, recovery_only=True)
|
||||
|
||||
|
||||
def emit_private_ready(fd, nonce, expected_envelope_sha256, envelope_sha256, envelope, receipt):
|
||||
# A value-free synchronization marker, never authentication or a grant.
|
||||
require(type(fd) is int and fd >= 3, 'private_ready_pipe_required')
|
||||
require(isinstance(nonce, str) and len(nonce) == 64
|
||||
and all(c in '0123456789abcdef' for c in nonce), 'ready_nonce_required')
|
||||
require(expected_envelope_sha256 == envelope_sha256, 'ready_envelope_pin_changed')
|
||||
validated = False
|
||||
try:
|
||||
current = os.fstat(fd)
|
||||
require(stat.S_ISFIFO(current.st_mode)
|
||||
and fcntl.fcntl(fd, fcntl.F_GETFL) & os.O_ACCMODE == os.O_WRONLY,
|
||||
'private_ready_pipe_required')
|
||||
identity = (current.st_dev, current.st_ino)
|
||||
require(all(identity != (os.fstat(other).st_dev, os.fstat(other).st_ino)
|
||||
for other in (0, 1, 2)), 'private_ready_stdio_alias_refused')
|
||||
validated = True
|
||||
check_deadline()
|
||||
marker = {'schema': READY_SCHEMA, 'session_nonce': nonce,
|
||||
'proposal_id': PROPOSAL, 'target': TARGET,
|
||||
'executor_sha256': digest(Path(__file__).read_bytes()),
|
||||
'envelope_sha256': envelope_sha256,
|
||||
'bao_health_identity_sha256': envelope['bao_health_identity_sha256'],
|
||||
'valid_until_epoch': envelope['valid_until_epoch'],
|
||||
'receive_budget_seconds': request_budget(PRIVATE_INPUT_TOTAL_SECONDS)}
|
||||
payload = json.dumps(marker, sort_keys=True, separators=(',', ':')).encode() + b'\n'
|
||||
require(len(payload) <= READY_MAX_BYTES, 'private_ready_oversized')
|
||||
with total_timeout(request_budget(2)):
|
||||
offset = 0
|
||||
while offset < len(payload):
|
||||
written = os.write(fd, payload[offset:])
|
||||
require(type(written) is int and written > 0, 'private_ready_delivery_failed')
|
||||
offset += written
|
||||
check_deadline()
|
||||
receipt['private_ready_emitted'] = True
|
||||
except Refused:
|
||||
raise
|
||||
except Exception:
|
||||
raise Refused('private_ready_delivery_failed') from None
|
||||
finally:
|
||||
if validated:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
def read_private_input(stream):
|
||||
# Complete private JSON plus EOF, within this original approved action window.
|
||||
# No unbounded read, text buffering, token logging or renewed deadline.
|
||||
check_deadline()
|
||||
budget = request_budget(PRIVATE_INPUT_TOTAL_SECONDS)
|
||||
binary = getattr(stream, 'buffer', None)
|
||||
require(binary is not None, 'private_binary_input_required')
|
||||
chunks, size = [], 0
|
||||
with total_timeout(budget):
|
||||
while True:
|
||||
chunk = binary.read(min(4096, PRIVATE_INPUT_MAX_BYTES + 1 - size))
|
||||
require(isinstance(chunk, bytes), 'private_binary_input_required')
|
||||
if not chunk:
|
||||
break
|
||||
size += len(chunk)
|
||||
require(size <= PRIVATE_INPUT_MAX_BYTES, 'private_input_oversized')
|
||||
chunks.append(chunk)
|
||||
check_deadline()
|
||||
require(size > 0, 'private_input_empty')
|
||||
try:
|
||||
private = read_json(b''.join(chunks))
|
||||
require(isinstance(private, dict)
|
||||
and set(private) == {'human_token', 'coding_agent_token'}
|
||||
and all(isinstance(value, str) and
|
||||
0 < len(value.encode('utf-8')) <= PRIVATE_TOKEN_MAX_BYTES
|
||||
for value in private.values()), 'private_input_invalid')
|
||||
except (TypeError, ValueError, UnicodeError, Refused):
|
||||
raise Refused('private_input_invalid') from None
|
||||
check_deadline()
|
||||
return private
|
||||
|
||||
|
||||
def start_execution_window(envelope):
|
||||
global REQUEST_DEADLINE, BUSINESS_MONOTONIC_DEADLINE, CLEANUP_MONOTONIC_DEADLINE, CLEANING
|
||||
REQUEST_DEADLINE = envelope['valid_until_epoch']
|
||||
BUSINESS_MONOTONIC_DEADLINE = time.monotonic() + min(
|
||||
REQUEST_DEADLINE - time.time(), envelope['maximum_session_seconds'] - 180)
|
||||
CLEANUP_MONOTONIC_DEADLINE = None
|
||||
CLEANING = False
|
||||
|
||||
|
||||
def execute(envelope, private, receipt, checkpoint=lambda: None, window_started=False):
|
||||
global CLEANING
|
||||
leeway = verify_sources(envelope)
|
||||
if not window_started:
|
||||
start_execution_window(envelope)
|
||||
else:
|
||||
# main already began this exact window before consuming private stdin.
|
||||
# Preserve its original monotonic budget; input waiting cannot renew it.
|
||||
require(REQUEST_DEADLINE == envelope['valid_until_epoch']
|
||||
and BUSINESS_MONOTONIC_DEADLINE is not None and not CLEANING,
|
||||
'preinput_execution_window_required')
|
||||
check_deadline()
|
||||
runner_path = envelope['runner_path']
|
||||
require(runner_path in envelope['source_hashes'], 'runner_pin_required')
|
||||
probe = load_probe(runner_path)
|
||||
transport = SessionTransport(envelope['bao_addr'], receipt=receipt)
|
||||
discovery = transport.discovery()
|
||||
require(discovery.get('issuer') == 'https://kc.coulomb.social' and
|
||||
discovery.get('token_endpoint') == 'https://kc.coulomb.social/token',
|
||||
'issuer_discovery_changed')
|
||||
verify_bao_route(transport, envelope, receipt)
|
||||
require(isinstance(private.get('human_token'), str) and private['human_token']
|
||||
and isinstance(private.get('coding_agent_token'), str) and private['coding_agent_token'],
|
||||
'founder_private_tokens_required')
|
||||
require(k(['get', 'namespace', 'kube-system', '-o', 'json'])['metadata']['uid'] == CLUSTER,
|
||||
'cluster_identity_changed')
|
||||
before = get('secret', 'keycape-config')
|
||||
deployment = get('deployment', 'keycape')
|
||||
require(not before['metadata'].get('ownerReferences'), 'controller_owned_config')
|
||||
require(meta(before) == envelope['config_metadata']
|
||||
and meta(deployment) == envelope['deployment_metadata']
|
||||
and canonical(deployment['spec']) == envelope['deployment_spec_sha256'],
|
||||
'approved_runtime_revision_changed')
|
||||
raw = base64.b64decode(before['data']['config.yaml'], validate=True).decode()
|
||||
require(digest(raw.encode()) == envelope['registry_sha256'], 'approved_registry_changed')
|
||||
updated = base64.b64encode(candidate_config(raw).encode()).decode()
|
||||
intended_data = dict(before['data'], **{'config.yaml': updated})
|
||||
candidate = candidate_spec(deployment['spec'])
|
||||
ready(deployment['spec'])
|
||||
# Exact GET existence is tested via create (never adopt/delete an existing Secret).
|
||||
values = {c: secrets.token_urlsafe(48) for c in IDS}
|
||||
temporary = {'apiVersion': 'v1', 'kind': 'Secret', 'metadata': {'namespace': 'sso', 'name': TEMP},
|
||||
'type': 'Opaque', 'data': {c: base64.b64encode(v.encode()).decode() for c, v in values.items()}}
|
||||
patch('secret', before, '/data/config.yaml', updated, dry=True)
|
||||
patch('deployment', deployment, '/spec', candidate, dry=True)
|
||||
k(['create', '-f', '/dev/stdin', '--dry-run=server', '-o', 'json'], temporary)
|
||||
created = None
|
||||
mutations_attempted = False
|
||||
began = time.monotonic()
|
||||
try:
|
||||
# Set before API call: uncertain response must still trigger reconciliation.
|
||||
check_deadline()
|
||||
verify_bao_route(transport, envelope, receipt)
|
||||
mutations_attempted = True
|
||||
created = k(['create', '-f', '/dev/stdin', '-o', 'json'], temporary)
|
||||
receipt['temporary_secret_uid'] = created['metadata']['uid']
|
||||
checkpoint()
|
||||
patch('secret', before, '/data/config.yaml', updated)
|
||||
patch('deployment', deployment, '/spec', candidate)
|
||||
ready(candidate)
|
||||
require(get('secret', 'keycape-config')['data'] == intended_data, 'registry_drift')
|
||||
require(time.monotonic() - began < 300, 'probe_start_too_late')
|
||||
# Current admitted agent token stays founder-owned; no existing secret fetched.
|
||||
original_exchange = probe.exchange
|
||||
def exchange(post, endpoint, client, secret, scope):
|
||||
if client == 'codex-railiance-platform':
|
||||
return private['coding_agent_token']
|
||||
return original_exchange(post, endpoint, client, secret, scope)
|
||||
probe.exchange = exchange
|
||||
supplied = dict(values, **{'codex-railiance-platform': 'founder-held-token'})
|
||||
def bounded_post(*args, **kwargs):
|
||||
if not args[0].endswith('/v1/auth/token/revoke-self'):
|
||||
require(time.monotonic() - began < envelope['maximum_session_seconds'] - 180,
|
||||
'session_cleanup_reserve_reached')
|
||||
return transport.post(*args, **kwargs)
|
||||
def bounded_sleep(seconds):
|
||||
while seconds > 0:
|
||||
check_deadline()
|
||||
require(time.monotonic() - began < envelope['maximum_session_seconds'] - 180,
|
||||
'session_cleanup_reserve_reached')
|
||||
part = min(seconds, 3)
|
||||
time.sleep(part)
|
||||
seconds -= part
|
||||
result = probe.run(bounded_post, supplied, 'https://kc.coulomb.social/token',
|
||||
envelope['bao_addr'], 'keycape-services', 'secrets-engine-openbao',
|
||||
human_token=private['human_token'], leeway=leeway, sleep=bounded_sleep)
|
||||
receipt['negative_probe'] = result
|
||||
checkpoint()
|
||||
check_deadline()
|
||||
require(time.monotonic() - began <= envelope['maximum_session_seconds'], 'session_window_exceeded')
|
||||
require(result['all_refused'], 'negative_proof_incomplete')
|
||||
finally:
|
||||
if mutations_attempted:
|
||||
begin_cleanup()
|
||||
CLEANING = True
|
||||
# Never restore over an unrelated edit; keep custody for owner recovery on drift.
|
||||
now = get('secret', 'keycape-config')
|
||||
require(now['metadata']['uid'] == before['metadata']['uid'] and
|
||||
now['data'] in [before['data'], intended_data], 'cleanup_registry_drift')
|
||||
if now['data'] == intended_data:
|
||||
patch('secret', now, '/data/config.yaml', before['data']['config.yaml'])
|
||||
now = get('deployment', 'keycape')
|
||||
require(now['metadata']['uid'] == deployment['metadata']['uid'] and
|
||||
now['spec'] in [deployment['spec'], candidate], 'cleanup_deployment_drift')
|
||||
if now['spec'] == candidate:
|
||||
patch('deployment', now, '/spec', deployment['spec'])
|
||||
ready(deployment['spec'])
|
||||
require(get('secret', 'keycape-config')['data'] == before['data'], 'cleanup_readback_failed')
|
||||
# No fresh JWT is minted: deleted clients must fail at the issuer.
|
||||
for client in IDS:
|
||||
basic = base64.b64encode((client + ':' + values[client]).encode()).decode()
|
||||
status, body = transport.post('https://kc.coulomb.social/token',
|
||||
form={'grant_type': 'client_credentials', 'scope': 'openbao:login'},
|
||||
headers={'Authorization': 'Basic ' + basic})
|
||||
require(removed_client_denied(status, body),
|
||||
'removed_client_denial_unconfirmed')
|
||||
if created is None:
|
||||
# A timed-out create may have succeeded; compare generated bytes before delete.
|
||||
observed = get('secret', TEMP)
|
||||
require(observed.get('data') == temporary['data'], 'temporary_secret_identity_unconfirmed')
|
||||
created = observed
|
||||
observed = get('secret', TEMP)
|
||||
require(observed['metadata']['uid'] == created['metadata']['uid']
|
||||
and observed.get('data') == temporary['data'], 'temporary_secret_drift')
|
||||
delete_temporary(observed)
|
||||
receipt['cleanup_verified'] = True
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
ap.add_argument('mode', choices=['inspect', 'route-preflight', 'founder-preflight', 'execute', 'recover'])
|
||||
ap.add_argument('--envelope')
|
||||
ap.add_argument('--bao-addr', choices=sorted(BAO_ORIGINS))
|
||||
ap.add_argument('--ready-fd', type=int)
|
||||
ap.add_argument('--session-nonce')
|
||||
ap.add_argument('--ready-envelope-sha256')
|
||||
ap.add_argument('--confirm')
|
||||
ap.add_argument('--receipt', required=True)
|
||||
args = ap.parse_args()
|
||||
receipt = {'proposal_id': PROPOSAL, 'target': TARGET, 'native_accepted': False,
|
||||
'values_emitted': False, 'cleanup_verified': False}
|
||||
# Do not truncate or follow a link; protected receipts cannot overwrite credentials.
|
||||
fd = os.open(args.receipt, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
|
||||
def checkpoint():
|
||||
os.lseek(fd, 0, os.SEEK_SET)
|
||||
os.ftruncate(fd, 0)
|
||||
os.write(fd, json.dumps(receipt, sort_keys=True, indent=2).encode())
|
||||
os.fsync(fd)
|
||||
rc = 1
|
||||
try:
|
||||
if args.mode == 'route-preflight':
|
||||
require(args.bao_addr is not None, 'bao_origin_required')
|
||||
transport = SessionTransport(args.bao_addr, receipt=receipt)
|
||||
receipt.update(bao_health_identity_sha256=transport.health_identity(),
|
||||
bao_public_route_verified=True, mode='public_route_preflight',
|
||||
probe_location='current_process_only')
|
||||
elif args.mode in ['inspect', 'founder-preflight']:
|
||||
require(k(['get', 'namespace', 'kube-system', '-o', 'json'])['metadata']['uid'] == CLUSTER,
|
||||
'cluster_identity_changed')
|
||||
dep = get('deployment', 'keycape')
|
||||
receipt.update(deployment_metadata=meta(dep), deployment_spec_sha256=canonical(dep['spec']),
|
||||
image=next(c['image'] for c in dep['spec']['template']['spec']['containers']
|
||||
if c['name'] == 'keycape'), mode='metadata_only')
|
||||
if args.mode == 'founder-preflight':
|
||||
require(args.confirm == 'FOUNDER ATTENDED KEY39 PREFLIGHT', 'founder_attendance_required')
|
||||
require(args.bao_addr is not None, 'bao_origin_required')
|
||||
transport = SessionTransport(args.bao_addr, receipt=receipt)
|
||||
receipt.update(bao_health_identity_sha256=transport.health_identity(),
|
||||
bao_public_route_verified=True)
|
||||
config = get('secret', 'keycape-config')
|
||||
require(not config['metadata'].get('ownerReferences'), 'controller_owned_config')
|
||||
raw = base64.b64decode(config['data']['config.yaml'], validate=True).decode()
|
||||
candidate_config(raw)
|
||||
candidate_spec(dep['spec'])
|
||||
require(k(['-n', 'sso', 'get', 'secret', TEMP, '--ignore-not-found', '-o', 'json']) is None,
|
||||
'temporary_secret_already_exists')
|
||||
ready(dep['spec'])
|
||||
receipt.update(config_metadata=meta(config), registry_sha256=digest(raw.encode()),
|
||||
baseline_ready=True, mode='founder_protected_preflight',
|
||||
probe_ids_absent=True, probe_env_absent=True, temporary_secret_absent=True)
|
||||
else:
|
||||
require(args.envelope is not None, 'approval_envelope_required')
|
||||
envelope_raw = Path(args.envelope).read_bytes()
|
||||
envelope = read_json(envelope_raw)
|
||||
envelope_sha256 = digest(envelope_raw)
|
||||
require(Path(args.envelope).stat().st_mode & 0o022 == 0, 'approval_envelope_writable')
|
||||
signal.signal(signal.SIGTERM, lambda *_: (_ for _ in ()).throw(Refused('session_interrupted')))
|
||||
if args.mode == 'recover':
|
||||
recover(envelope, receipt)
|
||||
else:
|
||||
# Public route/source readiness precedes private token consumption.
|
||||
verify_sources(envelope)
|
||||
start_execution_window(envelope)
|
||||
transport = SessionTransport(envelope['bao_addr'], receipt=receipt)
|
||||
verify_bao_route(transport, envelope, receipt)
|
||||
emit_private_ready(args.ready_fd, args.session_nonce, args.ready_envelope_sha256,
|
||||
envelope_sha256, envelope, receipt)
|
||||
private = read_private_input(__import__('sys').stdin)
|
||||
execute(envelope, private, receipt, checkpoint=checkpoint, window_started=True)
|
||||
receipt['negative_and_cleanup_verified'] = True
|
||||
rc = 0
|
||||
except Refused as error:
|
||||
receipt.update(status='incomplete_requires_owner_review', reason=str(error))
|
||||
except BaseException:
|
||||
# Fixed outcome only: exception text may contain protected Kubernetes/HTTP data.
|
||||
receipt['status'] = 'incomplete_requires_owner_review'
|
||||
finally:
|
||||
checkpoint()
|
||||
os.close(fd)
|
||||
return rc
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
|
|
@ -0,0 +1,176 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Turn on KeyCape's sign-in diagnosis page for the openbao-admin client.
|
||||
|
||||
NK-WP-0051-T02 / KEY-WP-0041-T02. Adds one line, `signInDiagnosis: true`, to
|
||||
the existing openbao-admin registration in sso/keycape-config. The field
|
||||
contract is docs/sign-in-diagnosis.md; the image that honours the flag is
|
||||
key-cape sha256:cdec9dd7 (source 8c6c071), live since 2026-10-15.
|
||||
|
||||
Modes default to non-mutating. `inspect` reads and plans; `dry-run` sends a
|
||||
server-side dry-run patch; `apply` writes and is reserved for the attended
|
||||
operator with `--attended` and the observed uid/resourceVersion. `--operation
|
||||
remove` is the rollback: it removes only that one line. Either way the issuer
|
||||
reads the change only after `kubectl -n sso rollout restart deploy/keycape`.
|
||||
|
||||
Exact-match edit: flag absent -> add; already true -> noop; any other value,
|
||||
a missing or duplicated openbao-admin client -> fail closed. The signing key,
|
||||
every other client and every other config byte are preserved. No Secret value,
|
||||
parser message or Kubernetes error text leaves the process.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import copy
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
ROOT = Path(__file__).resolve().parent
|
||||
|
||||
|
||||
def module(name, filename):
|
||||
spec = importlib.util.spec_from_file_location(name, ROOT / filename)
|
||||
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', 'portal-client-rollout.py')
|
||||
pin = lane.pin
|
||||
Refused = lane.Refused
|
||||
require = lane.require
|
||||
|
||||
CLIENT_ID = 'openbao-admin'
|
||||
FLAG = 'signInDiagnosis'
|
||||
LINE = FLAG + ': true'
|
||||
|
||||
|
||||
def _inspect(secret):
|
||||
raw, config, _ = pin.issuer_document(secret)
|
||||
require(config['authelia'].get('issuer') == pin.VERIFIED_UPSTREAM_ISSUER,
|
||||
'verified_issuer_pin_required')
|
||||
clients = config.get('clients')
|
||||
require(isinstance(clients, list) and clients, 'client_sequence_required')
|
||||
require(all(isinstance(c, dict) and 'clientId' in c for c in clients), 'client_shape_unexpected')
|
||||
ids = [c['clientId'] for c in clients]
|
||||
require(len(ids) == len(set(ids)), 'duplicate_client_id')
|
||||
require(CLIENT_ID in ids, 'openbao_client_missing')
|
||||
position = ids.index(CLIENT_ID)
|
||||
value = clients[position].get(FLAG, None)
|
||||
require(value in (None, True), 'existing_flag_differs_requires_reconciliation')
|
||||
root = yaml.compose(raw)
|
||||
sequence = next(v for k, v in root.value if k.value == 'clients')
|
||||
require(isinstance(sequence, yaml.nodes.SequenceNode) and not sequence.flow_style,
|
||||
'block_client_sequence_required')
|
||||
item = sequence.value[position]
|
||||
require(isinstance(item, yaml.nodes.MappingNode) and not item.flow_style,
|
||||
'block_client_mapping_required')
|
||||
return raw, config, position, item
|
||||
|
||||
|
||||
def _line_end(raw, index):
|
||||
end = raw.find('\n', index)
|
||||
return len(raw) if end < 0 else end + 1
|
||||
|
||||
|
||||
def enable(secret):
|
||||
"""Return (base64 config, changed) with only the flag line added."""
|
||||
raw, config, position, item = _inspect(secret)
|
||||
if config['clients'][position].get(FLAG) is True:
|
||||
return secret['data']['config.yaml'], False
|
||||
key, value = item.value[0]
|
||||
require(isinstance(value, yaml.nodes.ScalarNode)
|
||||
and key.start_mark.line == value.end_mark.line, 'unexpected_item_layout')
|
||||
index = _line_end(raw, value.end_mark.index)
|
||||
prefix = '' if raw[index - 1:index] == '\n' else '\n'
|
||||
updated = raw[:index] + prefix + ' ' * key.start_mark.column + LINE + '\n' + raw[index:]
|
||||
expected = copy.deepcopy(config)
|
||||
expected['clients'][position][FLAG] = True
|
||||
require(yaml.load(updated, Loader=pin.UniqueLoader) == expected, 'unrelated_configuration_changed')
|
||||
return base64.b64encode(updated.encode()).decode(), True
|
||||
|
||||
|
||||
def disable(secret):
|
||||
"""Rollback: return (base64 config, changed) with only the flag line removed."""
|
||||
raw, config, position, item = _inspect(secret)
|
||||
if FLAG not in config['clients'][position]:
|
||||
return secret['data']['config.yaml'], False
|
||||
key = next(k for k, _ in item.value if k.value == FLAG)
|
||||
require(key is not item.value[0][0], 'unexpected_item_layout')
|
||||
start = raw.rfind('\n', 0, key.start_mark.index) + 1
|
||||
end = _line_end(raw, key.start_mark.index)
|
||||
require(raw[start:end].strip() == LINE, 'unexpected_item_layout')
|
||||
updated = raw[:start] + raw[end:]
|
||||
expected = copy.deepcopy(config)
|
||||
del expected['clients'][position][FLAG]
|
||||
require(yaml.load(updated, Loader=pin.UniqueLoader) == expected, 'unrelated_configuration_changed')
|
||||
return base64.b64encode(updated.encode()).decode(), True
|
||||
|
||||
|
||||
def run(args, kubectl=None):
|
||||
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)
|
||||
encoded, changed = (enable if args.operation == 'add' else disable)(before)
|
||||
receipt = {'schema': 'netkingdom.keycape-openbao-signin-diagnosis.v1',
|
||||
'target': 'sso/keycape-config', 'before': metadata, 'operation': args.operation,
|
||||
'client_id': CLIENT_ID, 'flag': FLAG, '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):
|
||||
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='add', choices=['add', 'remove'])
|
||||
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:
|
||||
print(json.dumps(run(args, kubectl), sort_keys=True))
|
||||
return 0
|
||||
except Refused as error:
|
||||
print(json.dumps({'status': 'refused', 'reason': str(error)}))
|
||||
except pin.IssuerPinError as error:
|
||||
print(json.dumps({'status': 'refused', 'reason': str(error)}))
|
||||
except Exception:
|
||||
# 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())
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
#!/usr/bin/env bash
|
||||
# Legacy unguarded registry mutation withdrawn under NK-WP-0049.
|
||||
set +x
|
||||
printf '%s\n' 'Retired: use the reviewed KeyCape client registration/rollout adapter with exact source, object/version checks, contained diagnostics and owner authorization. No registry or credential is read or changed here.' >&2
|
||||
exit 2
|
||||
|
|
@ -0,0 +1,236 @@
|
|||
"""Private, digest-bound Playwright registry/Deployment patch preparation.
|
||||
|
||||
No CLI or Kubernetes client: the existing protected owner execution path must
|
||||
provide fresh objects, an independently approved proposal digest, admitted
|
||||
custody, exclusive maintenance, and readiness/login verification. Proposal and
|
||||
patch bodies contain private registry data; never log or serialize them to chat.
|
||||
Only summary() is an output receipt. This library grants no execution authority.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import copy
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
|
||||
spec = importlib.util.spec_from_file_location('playwright_registration', Path(__file__).with_name('playwright-registration.py'))
|
||||
registration = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(registration)
|
||||
require = registration.require
|
||||
Refused = registration.Refused
|
||||
|
||||
|
||||
def digest(value):
|
||||
return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(',', ':'),
|
||||
allow_nan=False).encode()).hexdigest()
|
||||
|
||||
|
||||
def target(obj, kind, name):
|
||||
require(obj.get('kind') == kind and obj.get('metadata', {}).get('name') == name
|
||||
and obj['metadata'].get('namespace') == 'sso', 'unexpected_target')
|
||||
require(not obj['metadata'].get('deletionTimestamp') and
|
||||
not obj['metadata'].get('ownerReferences'), 'target_not_independently_available')
|
||||
return registration.registry.pin.safe_metadata(obj)
|
||||
|
||||
|
||||
def container(specification):
|
||||
items = specification['template']['spec']['containers']
|
||||
matches = [item for item in items if item.get('name') == 'keycape']
|
||||
require(len(matches) == 1, 'keycape_container_required')
|
||||
return matches[0]
|
||||
|
||||
|
||||
def stopped(specification):
|
||||
result = copy.deepcopy(specification)
|
||||
result['replicas'] = 0
|
||||
return result
|
||||
|
||||
|
||||
def build(secret, deployment, identities, source_revision):
|
||||
secret_meta = target(secret, 'Secret', 'keycape-config')
|
||||
deploy_meta = target(deployment, 'Deployment', 'keycape')
|
||||
require(not secret.get('immutable'), 'mutable_registry_required')
|
||||
require(re.fullmatch(r'[0-9a-f]{40}', source_revision or '') is not None, 'exact_source_revision_required')
|
||||
original = deployment['spec']
|
||||
require(original.get('replicas') == 1 and original.get('strategy', {}).get('type') == 'Recreate'
|
||||
and not original.get('paused'), 'single_recreate_issuer_required')
|
||||
current = container(original)
|
||||
require(re.fullmatch(r'forgejo\.coulomb\.social/coulomb/key-cape@sha256:[0-9a-f]{64}',
|
||||
current.get('image', '')) is not None, 'immutable_owner_image_required')
|
||||
require(not current.get('envFrom'), 'explicit_env_bindings_required')
|
||||
env = current.get('env', [])
|
||||
require(isinstance(env, list) and all(isinstance(e, dict) and isinstance(e.get('name'), str) for e in env),
|
||||
'env_shape_invalid')
|
||||
names = [e['name'] for e in env]
|
||||
require(len(names) == len(set(names)), 'duplicate_env_name')
|
||||
require([e for e in env if e['name'] == 'KEYCAPE_CONFIG'] ==
|
||||
[{'name': 'KEYCAPE_CONFIG', 'value': '/etc/keycape/config.yaml'}], 'config_path_drift')
|
||||
volumes = original['template']['spec'].get('volumes', [])
|
||||
mounts = current.get('volumeMounts', [])
|
||||
expected = [v['name'] for v in volumes if v.get('secret', {}).get('secretName') == 'keycape-config']
|
||||
require(len(expected) == 1 and any(m.get('name') == expected[0] and
|
||||
m.get('mountPath') == '/etc/keycape' and m.get('readOnly') is True and
|
||||
not m.get('subPath') and not m.get('subPathExpr') for m in mounts), 'registry_mount_drift')
|
||||
encoded, created = registration.prepare_registry(secret, identities)
|
||||
proposed_data = dict(secret['data'], **{'config.yaml': encoded})
|
||||
proposed_spec = copy.deepcopy(original)
|
||||
proposed_env = container(proposed_spec).setdefault('env', [])
|
||||
added_env = []
|
||||
records = registration.clients(identities)
|
||||
for record in records[1:]:
|
||||
binding = registration.registry.env_binding(record)
|
||||
target_ref = binding['valueFrom']['secretKeyRef']
|
||||
for entry in proposed_env:
|
||||
reference = entry.get('valueFrom', {}).get('secretKeyRef', {})
|
||||
require(entry['name'] == binding['name'] or
|
||||
(reference.get('name'), reference.get('key')) != (target_ref['name'], target_ref['key']),
|
||||
'delivery_secret_already_bound')
|
||||
existing = [e for e in proposed_env if e['name'] == binding['name']]
|
||||
require(not existing or existing == [binding], 'existing_binding_requires_reconciliation')
|
||||
if not existing:
|
||||
proposed_env.append(binding)
|
||||
added_env.append(binding['name'])
|
||||
return {'schema': 'netkingdom.playwright-activation.v1', 'source_revision': source_revision,
|
||||
'secret_identity': secret_meta, 'deployment_identity': deploy_meta,
|
||||
'before_data': copy.deepcopy(secret['data']), 'after_data': proposed_data,
|
||||
'before_spec': copy.deepcopy(original), 'after_spec': proposed_spec,
|
||||
'identity_inventory': copy.deepcopy(identities), 'created_client_ids': created,
|
||||
'created_env_names': added_env}
|
||||
|
||||
|
||||
def summary(proposal):
|
||||
changed = proposal['before_data'] != proposal['after_data'] or proposal['before_spec'] != proposal['after_spec']
|
||||
return {'schema': proposal['schema'], 'proposal_digest': digest(proposal),
|
||||
'source_revision': proposal['source_revision'],
|
||||
'created_client_ids': proposal['created_client_ids'],
|
||||
'created_env_names': proposal['created_env_names'],
|
||||
'change_needed': changed, 'maintenance_required': changed,
|
||||
'live_activated': False, 'custody_admitted': False}
|
||||
|
||||
|
||||
def check_registry(proposal, binary, expected_binary_sha256):
|
||||
"""Run the exact admitted native verifier, no ambient env or raw diagnostics."""
|
||||
path = Path(binary)
|
||||
require(path.is_file() and not path.is_symlink() and
|
||||
hashlib.sha256(path.read_bytes()).hexdigest() == expected_binary_sha256,
|
||||
'native_verifier_digest_mismatch')
|
||||
for stage in ('before', 'after'):
|
||||
env_names = [e['name'] for e in container(proposal[stage + '_spec']).get('env', [])]
|
||||
command = [str(path), 'check-config', '--config', '/dev/stdin', '--env-names',
|
||||
','.join(env_names) or '__NO_ENV_NAMES__']
|
||||
if 'KEYCAPE_POLICY_PATH' in env_names:
|
||||
command.append('--policy-store')
|
||||
try:
|
||||
result = subprocess.run(command, input=base64.b64decode(proposal[stage + '_data']['config.yaml'], validate=True),
|
||||
capture_output=True, timeout=60, env={'PATH': '/usr/bin:/bin'})
|
||||
report = json.loads(result.stdout)
|
||||
valid = result.returncode == 0 and report.get('valid') is True and not report.get('errors')
|
||||
except Exception:
|
||||
valid = False
|
||||
require(valid, 'native_registry_check_failed')
|
||||
return {'original_registry_valid': True, 'proposed_registry_valid': True,
|
||||
'verifier_sha256': expected_binary_sha256}
|
||||
|
||||
|
||||
def quiescent(deployment, pods):
|
||||
"""Caller supplies an unfiltered complete sso PodList from the same cluster."""
|
||||
status = deployment.get('status', {})
|
||||
require(status.get('observedGeneration') == deployment['metadata'].get('generation')
|
||||
and deployment['spec'].get('replicas') == 0 and
|
||||
all(status.get(k, 0) == 0 for k in ['replicas', 'readyReplicas', 'availableReplicas', 'updatedReplicas']),
|
||||
'issuer_not_stopped')
|
||||
require(pods.get('kind') == 'PodList' and not pods.get('metadata', {}).get('continue')
|
||||
and isinstance(pods.get('items'), list), 'complete_namespace_pod_list_required')
|
||||
# Terminating pods are still consumers. Do not infer quiescence from readiness.
|
||||
for pod in pods['items']:
|
||||
require(pod.get('metadata', {}).get('namespace') == 'sso', 'pod_inventory_namespace_mismatch')
|
||||
pod_spec = pod.get('spec', {})
|
||||
uses_registry = any(v.get('secret', {}).get('secretName') == 'keycape-config' or
|
||||
any(s.get('secret', {}).get('name') == 'keycape-config'
|
||||
for s in v.get('projected', {}).get('sources', []))
|
||||
for v in pod_spec.get('volumes', []))
|
||||
for c in pod_spec.get('containers', []) + pod_spec.get('initContainers', []) + pod_spec.get('ephemeralContainers', []):
|
||||
uses_registry = uses_registry or any(e.get('secretRef', {}).get('name') == 'keycape-config'
|
||||
for e in c.get('envFrom', [])) or any(
|
||||
e.get('valueFrom', {}).get('secretKeyRef', {}).get('name') == 'keycape-config'
|
||||
for e in c.get('env', []))
|
||||
require(not uses_registry, 'registry_consumer_still_present')
|
||||
|
||||
|
||||
def propose_step(proposal, approved_digest, phase, secret, deployment, pods):
|
||||
"""Return one private CAS patch or None for an exact no-op; never execute it.
|
||||
|
||||
The caller must freshly read BOTH objects before EACH phase, verify every
|
||||
response, and stop on unknown outcomes. The digest binds content, not authority.
|
||||
Kubernetes has no cross-object transaction; exclusive maintenance is required.
|
||||
"""
|
||||
require(digest(proposal) == approved_digest, 'proposal_revision_changed')
|
||||
secret_meta = target(secret, 'Secret', 'keycape-config')
|
||||
deploy_meta = target(deployment, 'Deployment', 'keycape')
|
||||
require(secret_meta['uid'] == proposal['secret_identity']['uid'] and
|
||||
deploy_meta['uid'] == proposal['deployment_identity']['uid'], 'target_identity_changed')
|
||||
old_data, new_data = proposal['before_data'], proposal['after_data']
|
||||
old_spec, new_spec = proposal['before_spec'], proposal['after_spec']
|
||||
data, current = secret['data'], deployment['spec']
|
||||
require(data == old_data or data == new_data, 'registry_drift_stop_without_stale_replay')
|
||||
require(any(current == item for item in [old_spec, new_spec, stopped(old_spec), stopped(new_spec)]),
|
||||
'deployment_drift_stop_without_stale_replay')
|
||||
require(phase in ('stop', 'registry', 'bindings', 'resume', 'rollback-stop',
|
||||
'rollback-registry', 'rollback-bindings', 'rollback-resume'), 'phase_invalid')
|
||||
if phase.startswith('rollback-') and data == old_data and current == old_spec:
|
||||
return None
|
||||
if old_data == new_data and old_spec == new_spec:
|
||||
require(data == old_data and current == old_spec, 'noop_state_changed')
|
||||
return None
|
||||
if phase == 'stop':
|
||||
require(secret_meta == proposal['secret_identity'] and deploy_meta == proposal['deployment_identity']
|
||||
and data == old_data and current == old_spec, 'initial_revision_changed')
|
||||
status = deployment.get('status', {})
|
||||
require(status.get('observedGeneration') == deployment['metadata'].get('generation') and
|
||||
all(status.get(k) == 1 for k in ['replicas', 'readyReplicas', 'availableReplicas', 'updatedReplicas']),
|
||||
'initial_issuer_not_ready')
|
||||
obj, field, wanted = deployment, 'spec', stopped(old_spec)
|
||||
elif phase == 'rollback-stop':
|
||||
obj, field, wanted = deployment, 'spec', stopped(current)
|
||||
else:
|
||||
quiescent(deployment, pods)
|
||||
if phase == 'registry':
|
||||
require(current == stopped(old_spec) and data == old_data, 'registry_phase_out_of_order')
|
||||
obj, field, wanted = secret, 'data', new_data
|
||||
elif phase == 'bindings':
|
||||
require(current == stopped(old_spec) and data == new_data, 'bindings_phase_out_of_order')
|
||||
obj, field, wanted = deployment, 'spec', stopped(new_spec)
|
||||
elif phase == 'resume':
|
||||
require(current == stopped(new_spec) and data == new_data, 'resume_phase_out_of_order')
|
||||
obj, field, wanted = deployment, 'spec', new_spec
|
||||
elif phase == 'rollback-registry':
|
||||
obj, field, wanted = secret, 'data', old_data
|
||||
elif phase == 'rollback-bindings':
|
||||
require(data == old_data, 'restore_registry_before_bindings')
|
||||
obj, field, wanted = deployment, 'spec', stopped(old_spec)
|
||||
else:
|
||||
require(data == old_data and current == stopped(old_spec), 'rollback_resume_out_of_order')
|
||||
obj, field, wanted = deployment, 'spec', old_spec
|
||||
if obj[field] == wanted:
|
||||
return None
|
||||
metadata = registration.registry.pin.safe_metadata(obj)
|
||||
patch = [{'op': 'test', 'path': '/metadata/uid', 'value': metadata['uid']},
|
||||
{'op': 'test', 'path': '/metadata/resourceVersion', 'value': metadata['resource_version']},
|
||||
{'op': 'test', 'path': '/' + field, 'value': copy.deepcopy(obj[field])},
|
||||
{'op': 'replace', 'path': '/' + field, 'value': copy.deepcopy(wanted)}]
|
||||
return {'kind': obj['kind'], 'namespace': 'sso', 'name': obj['metadata']['name'],
|
||||
'uid': metadata['uid'], 'field': field, 'expected': copy.deepcopy(wanted),
|
||||
'proposal_digest': approved_digest, 'phase': phase, 'patch': patch}
|
||||
|
||||
|
||||
def verify_response(operation, obj):
|
||||
"""Also use on a fresh GET after an acknowledged patch; failures stop the caller."""
|
||||
metadata = target(obj, operation['kind'], operation['name'])
|
||||
require(metadata['uid'] == operation['uid'] and obj.get(operation['field']) == operation['expected'],
|
||||
'mutation_readback_mismatch')
|
||||
return {'phase': operation['phase'], 'proposal_digest': operation['proposal_digest'],
|
||||
'uid': metadata['uid'], 'resource_version': metadata['resource_version'], 'readback_matches': True}
|
||||
|
|
@ -0,0 +1,162 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Prepare Playwright client records; no credential access or live mutation.
|
||||
|
||||
The registry adapter below is for an owner-controlled activation ceremony. Its
|
||||
encoded result contains the original private registry and must stay in memory.
|
||||
The CLI emits only newly constructed non-secret client/binding proposals.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import copy
|
||||
from decimal import Decimal
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
|
||||
spec = importlib.util.spec_from_file_location('playwright_registry', Path(__file__).with_name('keycape-client.py'))
|
||||
registry = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(registry)
|
||||
require = registry.require
|
||||
Refused = registry.Refused
|
||||
AUDIENCE = 'playwright-kubernetes'
|
||||
SCOPE = 'playwright:connect'
|
||||
CALLBACK = 'http://127.0.0.1:18764/callback'
|
||||
ISSUER = 'https://kc.coulomb.social'
|
||||
SLUG = re.compile(r'[a-z0-9][a-z0-9-]{0,39}\Z')
|
||||
IDENTIFIER = re.compile(r'[a-z0-9][a-z0-9:._-]{0,159}\Z')
|
||||
|
||||
|
||||
def public_client():
|
||||
return {'clientId': 'playwright-cli', 'audience': AUDIENCE,
|
||||
'displayName': 'Playwright platform consumer CLI',
|
||||
'redirectUris': [CALLBACK],
|
||||
'allowedScopes': ['openid', 'profile', 'groups', SCOPE],
|
||||
'grantTypes': ['authorization_code'], 'clientType': 'public'}
|
||||
|
||||
|
||||
def agent_client(identity):
|
||||
"""Reference an existing registered identity; never manufacture its subject/tenant."""
|
||||
require(isinstance(identity, dict) and set(identity) ==
|
||||
{'agent_id', 'service_subject', 'tenant', 'identity_record'}, 'agent_identity_fields_required')
|
||||
require(all(isinstance(v, str) for v in identity.values()), 'agent_identity_strings_required')
|
||||
require(SLUG.fullmatch(identity['agent_id']) is not None, 'agent_id_invalid')
|
||||
require(identity['service_subject'].startswith('service:') and
|
||||
IDENTIFIER.fullmatch(identity['service_subject']) is not None and
|
||||
len(identity['service_subject']) > len('service:'), 'registered_service_subject_required')
|
||||
require(identity['tenant'].startswith('tenant:') and
|
||||
IDENTIFIER.fullmatch(identity['tenant']) is not None and
|
||||
len(identity['tenant']) > len('tenant:'), 'registered_tenant_required')
|
||||
# This is a reference for owner review, not proof that registration exists.
|
||||
reference = identity['identity_record']
|
||||
require(1 <= len(reference) <= 512 and all(32 < ord(c) < 127 for c in reference),
|
||||
'identity_record_reference_required')
|
||||
cid = 'playwright-agent-' + identity['agent_id']
|
||||
return {'clientId': cid, 'audience': AUDIENCE,
|
||||
'displayName': 'Playwright agent ' + identity['agent_id'],
|
||||
'allowedScopes': [SCOPE], 'grantTypes': ['client_credentials'],
|
||||
'clientType': 'confidential', 'secretRef': 'env:' + registry.env_name(cid),
|
||||
'serviceSubject': identity['service_subject'], 'tenant': identity['tenant'],
|
||||
'roles': ['coding-agent'], 'tokenLifetime': '15m'}
|
||||
|
||||
|
||||
def clients(identities):
|
||||
require(isinstance(identities, list) and len(identities) <= 100, 'agent_inventory_invalid')
|
||||
result = [public_client()] + [agent_client(identity) for identity in identities]
|
||||
for field in ['clientId', 'serviceSubject', 'secretRef']:
|
||||
values = [c[field] for c in result if field in c]
|
||||
require(len(values) == len(set(values)), 'duplicate_agent_identity_or_binding')
|
||||
return result
|
||||
|
||||
|
||||
def human_lifetime(config):
|
||||
"""Bound the existing issuer setting; do not alter all humans' token lifetime."""
|
||||
raw = config.get('tokenLifetime')
|
||||
require(isinstance(raw, str) and 0 < len(raw) <= 128, 'explicit_human_token_lifetime_required')
|
||||
units = {'h': Decimal(3600), 'm': Decimal(60), 's': Decimal(1),
|
||||
'ms': Decimal('.001'), 'us': Decimal('.000001'),
|
||||
'µs': Decimal('.000001'), 'μs': Decimal('.000001'), 'ns': Decimal('.000000001')}
|
||||
parts = re.findall(r'([0-9]+(?:\.[0-9]*)?|\.[0-9]+)(ns|us|µs|μs|ms|s|m|h)', raw)
|
||||
require(parts and ''.join(value + unit for value, unit in parts) == raw,
|
||||
'human_token_lifetime_invalid')
|
||||
seconds = sum((Decimal(value) * units[unit] for value, unit in parts), Decimal(0))
|
||||
require(1 <= seconds <= 3600, 'human_token_lifetime_outside_cluster_bound')
|
||||
return seconds
|
||||
|
||||
|
||||
def prepare_registry(secret, identities, operation='add'):
|
||||
"""Byte-preserving proposal only. Caller owns CAS, check-config and readback.
|
||||
|
||||
Refuse drift before constructing any result. A missing client during removal
|
||||
is an idempotent no-op. Removal does not revoke issued tokens/connections.
|
||||
"""
|
||||
require(operation in ('add', 'remove'), 'operation_invalid')
|
||||
wanted = clients(identities)
|
||||
_, config, _ = registry.pin.issuer_document(secret)
|
||||
require(config.get('issuer') == ISSUER, 'keycape_issuer_mismatch')
|
||||
human_lifetime(config)
|
||||
require(isinstance(config.get('clients'), list), 'client_sequence_required')
|
||||
for existing in config['clients']:
|
||||
require(isinstance(existing, dict), 'client_shape_unexpected')
|
||||
if (existing.get('audience') or existing.get('clientId')) == AUDIENCE:
|
||||
require(existing in wanted, 'unreviewed_playwright_audience_registration')
|
||||
# Preflight every record against the same original, including collisions.
|
||||
for client in wanted:
|
||||
_, current, _ = registry._inspect(secret, client)
|
||||
if 'serviceSubject' in client:
|
||||
for other in current['clients']:
|
||||
if other['clientId'] != client['clientId'] and (other.get('audience') or other.get('clientId')) == AUDIENCE:
|
||||
require(other.get('serviceSubject') != client['serviceSubject'],
|
||||
'playwright_subject_already_registered')
|
||||
matching = [c for c in current['clients'] if c['clientId'] == client['clientId']]
|
||||
require(not matching or matching == [client], 'existing_registration_differs_requires_reconciliation')
|
||||
candidate = copy.deepcopy(secret)
|
||||
changed_ids = []
|
||||
merge = registry.replacement if operation == 'add' else registry.removal
|
||||
for client in wanted:
|
||||
encoded, changed = merge(candidate, client)
|
||||
candidate['data']['config.yaml'] = encoded
|
||||
if changed:
|
||||
changed_ids.append(client['clientId'])
|
||||
return candidate['data']['config.yaml'], changed_ids
|
||||
|
||||
|
||||
def unique_object(pairs):
|
||||
result = {}
|
||||
for key, value in pairs:
|
||||
require(key not in result, 'duplicate_input_field')
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(description=__doc__.split('\n', 1)[0])
|
||||
parser.add_argument('--agents', help='Non-secret JSON array of existing agent identity references')
|
||||
args = parser.parse_args(argv)
|
||||
try:
|
||||
identities = []
|
||||
if args.agents:
|
||||
with Path(args.agents).open('rb') as stream:
|
||||
raw = stream.read(65537)
|
||||
require(len(raw) <= 65536, 'agent_inventory_too_large')
|
||||
identities = json.loads(raw, object_pairs_hook=unique_object)
|
||||
records = clients(identities)
|
||||
print(json.dumps({'schema': 'netkingdom.playwright-registration.v1',
|
||||
'status': 'source_proposal', 'clients': records,
|
||||
'identity_references': {c['clientId']: identity['identity_record']
|
||||
for c, identity in zip(records[1:], identities)},
|
||||
'deployment_env': [registry.env_binding(c) for c in records if 'secretRef' in c],
|
||||
'identity_evidence_verified': False,
|
||||
'custody_admitted': False, 'live_activated': False}, indent=2))
|
||||
return 0
|
||||
except Refused as error:
|
||||
print(json.dumps({'status': 'refused', 'reason': str(error)}))
|
||||
except Exception:
|
||||
print(json.dumps({'status': 'refused', 'reason': 'invalid_registration_input'}))
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
|
|
@ -0,0 +1,159 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Restore the approved public portal client without exporting Secret values."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import copy
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
|
||||
import yaml
|
||||
|
||||
ROOT = Path(__file__).resolve().parent
|
||||
|
||||
|
||||
def module(name, filename):
|
||||
spec = importlib.util.spec_from_file_location(name, ROOT / filename)
|
||||
result = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(result)
|
||||
return result
|
||||
|
||||
|
||||
pin = module('issuer_pin', 'openbao-client-config.py')
|
||||
portal = module('portal_registration', 'register-user-engine-portal.py')
|
||||
CLUSTER_UID = 'a553c742-0115-43d4-99a4-a5ca56fe0786'
|
||||
|
||||
|
||||
class Refused(Exception):
|
||||
"""Only fixed reason codes may leave the operation."""
|
||||
|
||||
|
||||
def require(condition, reason):
|
||||
if not condition:
|
||||
raise Refused(reason)
|
||||
|
||||
|
||||
def replacement(secret):
|
||||
raw, config, _ = pin.issuer_document(secret)
|
||||
clients = config.get('clients')
|
||||
require(isinstance(clients, list) and clients, 'client_sequence_required')
|
||||
ids = [client['clientId'] for client in clients]
|
||||
require(len(ids) == len(set(ids)), 'duplicate_client_id')
|
||||
if portal.CLIENT_ID in ids:
|
||||
current = clients[ids.index(portal.CLIENT_ID)]
|
||||
if current == portal.CLIENT:
|
||||
return secret['data']['config.yaml'], False
|
||||
legacy = dict(portal.CLIENT, redirectUris=[
|
||||
'https://users.92-205-62-239.nip.io/oidc/callback'])
|
||||
require(current == legacy, 'existing_registration_differs_requires_reconciliation')
|
||||
# Only the exact reviewed legacy public client may gain this callback.
|
||||
root = yaml.compose(raw)
|
||||
node = next(value for key, value in root.value if key.value == 'clients')
|
||||
item = node.value[ids.index(portal.CLIENT_ID)]
|
||||
redirects = next(value for key, value in item.value if key.value == 'redirectUris')
|
||||
require(isinstance(redirects, yaml.nodes.SequenceNode) and not redirects.flow_style,
|
||||
'block_redirect_sequence_required')
|
||||
index = redirects.end_mark.index
|
||||
line_start = raw.rfind('\n', 0, index) + 1
|
||||
if not raw[line_start:index].strip():
|
||||
index = line_start
|
||||
addition = ' ' * redirects.start_mark.column + '- https://users.coulomb.social/oidc/callback\n'
|
||||
if index and raw[index - 1] != '\n':
|
||||
addition = '\n' + addition
|
||||
updated = raw[:index] + addition + raw[index:]
|
||||
expected = copy.deepcopy(config)
|
||||
expected['clients'][ids.index(portal.CLIENT_ID)] = portal.CLIENT
|
||||
require(yaml.load(updated, Loader=pin.UniqueLoader) == expected,
|
||||
'unrelated_configuration_changed')
|
||||
return base64.b64encode(updated.encode()).decode(), True
|
||||
root = yaml.compose(raw)
|
||||
node = next(value for key, value in root.value if key.value == 'clients')
|
||||
require(isinstance(node, yaml.nodes.SequenceNode) and not node.flow_style,
|
||||
'block_client_sequence_required')
|
||||
index = node.end_mark.index
|
||||
line_start = raw.rfind('\n', 0, index) + 1
|
||||
if not raw[line_start:index].strip():
|
||||
index = line_start
|
||||
indent = ' ' * node.start_mark.column
|
||||
addition = yaml.safe_dump([portal.CLIENT], sort_keys=False)
|
||||
addition = ''.join(indent + line if line.strip() else line
|
||||
for line in addition.splitlines(True))
|
||||
if index and raw[index - 1] != '\n':
|
||||
addition = '\n' + addition
|
||||
updated = raw[:index] + addition + raw[index:]
|
||||
expected = copy.deepcopy(config)
|
||||
expected['clients'].append(portal.CLIENT)
|
||||
require(yaml.load(updated, Loader=pin.UniqueLoader) == expected,
|
||||
'unrelated_configuration_changed')
|
||||
return base64.b64encode(updated.encode()).decode(), True
|
||||
|
||||
|
||||
def kubectl(arguments, payload=None):
|
||||
args = ['kubectl', '--kubeconfig', '/home/worsch/.kube/config-hosteurope',
|
||||
'--server', 'https://127.0.0.1:16444', '--request-timeout=20s', *arguments]
|
||||
result = subprocess.run(args, input=None if payload is None else json.dumps(payload),
|
||||
text=True, capture_output=True, timeout=30)
|
||||
require(result.returncode == 0, 'kubernetes_operation_failed')
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def run(args):
|
||||
cluster = kubectl(['get', 'namespace', 'kube-system', '-o', 'json'])
|
||||
require(cluster['metadata']['uid'] == 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)
|
||||
encoded, changed = replacement(before)
|
||||
receipt = {'target': 'sso/keycape-config', 'before': metadata,
|
||||
'client': portal.CLIENT, 'change_needed': changed,
|
||||
'mode': args.mode, 'values_emitted': False, 'changed': False}
|
||||
if args.mode == 'inspect':
|
||||
return receipt
|
||||
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))
|
||||
receipt.update(unrelated_config_bytes_preserved=True, other_secret_data_unchanged=True)
|
||||
return receipt
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('mode', choices=['inspect', 'dry-run', 'apply'])
|
||||
parser.add_argument('--expected-uid')
|
||||
parser.add_argument('--expected-resource-version')
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
print(json.dumps(run(args), sort_keys=True))
|
||||
return 0
|
||||
except Refused as error:
|
||||
print(json.dumps({'status': 'refused', 'reason': str(error)}))
|
||||
except Exception:
|
||||
# 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())
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
#!/usr/bin/env bash
|
||||
# Retired: KeyCape uses the scoped, renewable factor-reader lane.
|
||||
set +x
|
||||
set -euo pipefail
|
||||
printf '%s\n' 'ERROR: personal privacyIDEA admin-token delivery is retired.' 'Use railiance-platform RPF-WP-0040 scoped factor custody, ESO delivery and renewal recovery.' 'This helper performs no login, credential read, Secret change or restart.' >&2
|
||||
exit 1
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Idempotently register coulomb.social in the live KeyCape Secret.
|
||||
|
||||
The complete Secret travels over stdin/stdout between kubectl and this
|
||||
process. Secret values are never printed to the terminal or written to disk.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import sys
|
||||
|
||||
import yaml
|
||||
|
||||
CLIENT_ID = "coulomb-social"
|
||||
CLIENT = {
|
||||
"clientId": CLIENT_ID,
|
||||
"displayName": "coulomb.social",
|
||||
"redirectUris": [
|
||||
"http://127.0.0.1:8008/auth/callback/",
|
||||
"http://localhost:8008/auth/callback/",
|
||||
# Parallel public host while apex coulomb.social remains Bubble
|
||||
"https://app.coulomb.social/auth/callback/",
|
||||
# Future apex cutover (keep registered; not the live app host yet)
|
||||
"https://coulomb.social/auth/callback/",
|
||||
],
|
||||
"allowedScopes": ["openid", "profile", "email", "groups"],
|
||||
"grantTypes": ["authorization_code"],
|
||||
"clientType": "public",
|
||||
# Ordinary coulomb.social sessions are AAL1 unless the client explicitly
|
||||
# requests AAL2 through acr_values. Platform/admin clients keep their
|
||||
# existing provider-driven mandatory MFA behavior.
|
||||
"mfaRequired": False,
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
print("Retired: use the owner-reviewed KeyCape client registration adapter; raw registry output is disabled.", file=sys.stderr)
|
||||
raise SystemExit(2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
#!/usr/bin/env bash
|
||||
# Legacy unguarded registry mutation withdrawn under NK-WP-0049.
|
||||
set +x
|
||||
printf '%s\n' 'Retired: use the reviewed KeyCape client registration/rollout adapter with exact source, object/version checks, contained diagnostics and owner authorization. No registry or credential is read or changed here.' >&2
|
||||
exit 2
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Idempotently register the user-engine portal in the live KeyCape Secret.
|
||||
|
||||
The complete Secret travels over stdin/stdout between kubectl and this
|
||||
process. Secret values are never printed to the terminal or written to disk.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import sys
|
||||
|
||||
import yaml
|
||||
|
||||
CLIENT_ID = "user-engine-portal"
|
||||
CLIENT = {
|
||||
"clientId": CLIENT_ID,
|
||||
"displayName": "User Engine Portal",
|
||||
"redirectUris": ["https://users.92-205-62-239.nip.io/oidc/callback",
|
||||
"https://users.coulomb.social/oidc/callback"],
|
||||
"allowedScopes": ["openid", "profile", "email", "groups"],
|
||||
"grantTypes": ["authorization_code"],
|
||||
"clientType": "public",
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
secret = json.load(sys.stdin)
|
||||
encoded = secret.get("data", {}).get("config.yaml")
|
||||
if not encoded:
|
||||
raise SystemExit("keycape-config does not contain config.yaml")
|
||||
config = yaml.safe_load(base64.b64decode(encoded))
|
||||
clients = config.setdefault("clients", [])
|
||||
existing = next(
|
||||
(index for index, client in enumerate(clients) if client.get("clientId") == CLIENT_ID),
|
||||
None,
|
||||
)
|
||||
if existing is None:
|
||||
clients.append(CLIENT)
|
||||
else:
|
||||
clients[existing] = CLIENT
|
||||
rendered = yaml.safe_dump(config, sort_keys=False).encode()
|
||||
secret["data"]["config.yaml"] = base64.b64encode(rendered).decode()
|
||||
secret.pop("status", None)
|
||||
metadata = secret.get("metadata", {})
|
||||
for key in ("creationTimestamp", "managedFields", "resourceVersion", "uid"):
|
||||
metadata.pop(key, None)
|
||||
json.dump(secret, sys.stdout, separators=(",", ":"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -0,0 +1,222 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Owner-specific KeyCape registration of the secrets-engine-openbao service client.
|
||||
|
||||
KEY-WP-0037-T03 / NK-WP-0046. Source preparation only: the live operation
|
||||
needs the founder's separate go (State Hub decision
|
||||
caafe4be-4f68-47b4-9567-316d47b94992) and the platform-materialised Secret
|
||||
keycape-secrets-engine-openbao-client (key client-secret). This helper merges
|
||||
only the client registration into sso/keycape-config; the single Deployment
|
||||
env binding is the separate proposed patch
|
||||
secrets-engine-openbao-client.proposed.patch.yaml.
|
||||
|
||||
Modes default to non-mutating. `inspect` reads and plans; `dry-run` sends a
|
||||
server-side dry-run patch; `apply` writes and is reserved for the attended
|
||||
operator under the live go. `--operation remove` is the rollback: it removes
|
||||
only this exact registration and nothing else.
|
||||
|
||||
Exact-match merge: absent -> add only this client; identical -> noop;
|
||||
duplicates, drift or a shared secretRef -> fail closed. The signing key,
|
||||
unrelated clients, policy and every other config byte are preserved. No Secret
|
||||
value, parser message or Kubernetes error text leaves the process.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import copy
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
ROOT = Path(__file__).resolve().parent
|
||||
|
||||
|
||||
def module(name, filename):
|
||||
spec = importlib.util.spec_from_file_location(name, ROOT / filename)
|
||||
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', 'portal-client-rollout.py')
|
||||
pin = lane.pin
|
||||
Refused = lane.Refused
|
||||
require = lane.require
|
||||
|
||||
LIVE_GO_DECISION = 'caafe4be-4f68-47b4-9567-316d47b94992'
|
||||
CLIENT_ID = 'secrets-engine-openbao'
|
||||
ENV_NAME = 'KEYCAPE_SECRETS_ENGINE_OPENBAO_CLIENT_SECRET'
|
||||
SECRET_NAME = 'keycape-secrets-engine-openbao-client'
|
||||
SECRET_KEY = 'client-secret'
|
||||
# Verbatim from key-cape config/service-clients.example.yaml (reviewed at
|
||||
# KeyCape e22029e). No `audience` key: the audience defaults to the clientId.
|
||||
CLIENT = {
|
||||
'clientId': CLIENT_ID,
|
||||
'displayName': 'secrets-engine OpenBao login',
|
||||
'allowedScopes': ['openbao:login'],
|
||||
'grantTypes': ['client_credentials'],
|
||||
'clientType': 'confidential',
|
||||
'secretRef': 'env:' + ENV_NAME,
|
||||
'serviceSubject': 'service:secrets-engine',
|
||||
'tenant': 'tenant:platform',
|
||||
'roles': ['secrets-engine'],
|
||||
'tokenLifetime': '15m',
|
||||
}
|
||||
ENV_BINDING = {'name': ENV_NAME, 'valueFrom': {
|
||||
'secretKeyRef': {'name': SECRET_NAME, 'key': SECRET_KEY}}}
|
||||
|
||||
|
||||
def _client_node(raw):
|
||||
root = yaml.compose(raw)
|
||||
node = next(value for key, value in root.value if key.value == 'clients')
|
||||
require(isinstance(node, yaml.nodes.SequenceNode) and not node.flow_style,
|
||||
'block_client_sequence_required')
|
||||
return node
|
||||
|
||||
|
||||
def _line_start(raw, index):
|
||||
start = raw.rfind('\n', 0, index) + 1
|
||||
return start if not raw[start:index].strip() else index
|
||||
|
||||
|
||||
def _inspect(secret):
|
||||
raw, config, _ = pin.issuer_document(secret)
|
||||
require(config['authelia'].get('issuer') == pin.VERIFIED_UPSTREAM_ISSUER,
|
||||
'verified_issuer_pin_required')
|
||||
clients = config.get('clients')
|
||||
require(isinstance(clients, list) and clients, 'client_sequence_required')
|
||||
require(all(isinstance(c, dict) and 'clientId' in c for c in clients), 'client_shape_unexpected')
|
||||
ids = [c['clientId'] for c in clients]
|
||||
require(len(ids) == len(set(ids)), 'duplicate_client_id')
|
||||
others = [c for c in clients if c['clientId'] != CLIENT_ID]
|
||||
require(all(c.get('secretRef') != CLIENT['secretRef'] for c in others), 'secret_ref_already_bound')
|
||||
return raw, config, ids
|
||||
|
||||
|
||||
def replacement(secret):
|
||||
"""Return (base64 config, changed) with only this client added."""
|
||||
raw, config, ids = _inspect(secret)
|
||||
if CLIENT_ID in ids:
|
||||
require(config['clients'][ids.index(CLIENT_ID)] == CLIENT,
|
||||
'existing_registration_differs_requires_reconciliation')
|
||||
return secret['data']['config.yaml'], False
|
||||
node = _client_node(raw)
|
||||
index = _line_start(raw, node.end_mark.index)
|
||||
indent = ' ' * node.start_mark.column
|
||||
fragment = yaml.safe_dump([CLIENT], sort_keys=False, width=120)
|
||||
fragment = ''.join(indent + line if line.strip() else line for line in fragment.splitlines(True))
|
||||
if index and raw[index - 1] != '\n':
|
||||
fragment = '\n' + fragment
|
||||
updated = raw[:index] + fragment + raw[index:]
|
||||
expected = copy.deepcopy(config)
|
||||
expected['clients'].append(CLIENT)
|
||||
require(yaml.load(updated, Loader=pin.UniqueLoader) == expected, 'unrelated_configuration_changed')
|
||||
return base64.b64encode(updated.encode()).decode(), True
|
||||
|
||||
|
||||
def removal(secret):
|
||||
"""Rollback: return (base64 config, changed) with only this exact client removed."""
|
||||
raw, config, ids = _inspect(secret)
|
||||
if CLIENT_ID not in ids:
|
||||
return secret['data']['config.yaml'], False
|
||||
position = ids.index(CLIENT_ID)
|
||||
require(config['clients'][position] == CLIENT, 'existing_registration_differs_requires_reconciliation')
|
||||
require(len(ids) > 1, 'removal_would_empty_client_sequence')
|
||||
node = _client_node(raw)
|
||||
item = node.value[position]
|
||||
start = raw.rfind('\n', 0, item.start_mark.index) + 1
|
||||
require(raw[start:item.start_mark.index].strip() == '-', 'unexpected_item_layout')
|
||||
if position + 1 < len(node.value):
|
||||
following = node.value[position + 1].start_mark.index
|
||||
end = raw.rfind('\n', 0, following) + 1
|
||||
else:
|
||||
end = _line_start(raw, node.end_mark.index)
|
||||
updated = raw[:start] + raw[end:]
|
||||
expected = copy.deepcopy(config)
|
||||
del expected['clients'][position]
|
||||
require(yaml.load(updated, Loader=pin.UniqueLoader) == expected, 'unrelated_configuration_changed')
|
||||
return base64.b64encode(updated.encode()).decode(), True
|
||||
|
||||
|
||||
def deployment_binding(deployment):
|
||||
"""Report whether the live Deployment already carries exactly the one binding."""
|
||||
containers = deployment['spec']['template']['spec']['containers']
|
||||
container = next(c for c in containers if c['name'] == 'keycape')
|
||||
matches = [e for e in container.get('env', []) if e.get('name') == ENV_NAME]
|
||||
require(len(matches) <= 1 and all(e == ENV_BINDING for e in matches), 'env_binding_drift')
|
||||
return bool(matches)
|
||||
|
||||
|
||||
def run(args):
|
||||
kubectl = 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)
|
||||
encoded, changed = (replacement if args.operation == 'add' else removal)(before)
|
||||
deployment = kubectl(['-n', 'sso', 'get', 'deployment', 'keycape', '-o', 'json'])
|
||||
bound = deployment_binding(deployment)
|
||||
receipt = {'schema': 'netkingdom.keycape-secrets-engine-openbao-client.v1',
|
||||
'target': 'sso/keycape-config', 'before': metadata, 'operation': args.operation,
|
||||
'client_id': CLIENT_ID, 'change_needed': changed, 'mode': args.mode,
|
||||
'deployment_env_bound': bound, 'values_emitted': False, 'changed': False}
|
||||
if args.mode == 'inspect':
|
||||
return receipt
|
||||
if args.mode == 'apply':
|
||||
# Attended only: the founder's live go must be named and the plan revision pinned.
|
||||
require(args.live_go_decision == LIVE_GO_DECISION, 'founder_live_go_required')
|
||||
if args.operation == 'add':
|
||||
# Startup resolves secretRef; a registration without its env binding
|
||||
# would make the next pod start fail closed.
|
||||
require(bound, 'deployment_env_binding_required_first')
|
||||
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):
|
||||
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='add', choices=['add', 'remove'])
|
||||
parser.add_argument('--expected-uid')
|
||||
parser.add_argument('--expected-resource-version')
|
||||
parser.add_argument('--live-go-decision', help='required for apply: ' + LIVE_GO_DECISION)
|
||||
args = parser.parse_args(argv)
|
||||
try:
|
||||
print(json.dumps(run(args), sort_keys=True))
|
||||
return 0
|
||||
except Refused as error:
|
||||
print(json.dumps({'status': 'refused', 'reason': str(error)}))
|
||||
except pin.IssuerPinError as error:
|
||||
print(json.dumps({'status': 'refused', 'reason': str(error)}))
|
||||
except Exception:
|
||||
# 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())
|
||||
|
|
@ -0,0 +1,141 @@
|
|||
import base64
|
||||
import copy
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
import yaml
|
||||
|
||||
PATH = Path(__file__).with_name('approval-clients-rollout.py')
|
||||
spec = importlib.util.spec_from_file_location('rollout', PATH)
|
||||
m = importlib.util.module_from_spec(spec); spec.loader.exec_module(m)
|
||||
|
||||
|
||||
def fixture(raw=None):
|
||||
raw = raw or 'authelia:\n issuer: https://auth.coulomb.social\n clientSecret: synthetic-fixture\nclients:\n - clientId: existing-human\n allowedScopes: [openid]\n# trailing owner comment\nother: preserved\n'
|
||||
return {'kind': 'Secret', 'metadata': {'name': 'keycape-config', 'namespace': 'sso',
|
||||
'uid': '2e94519d-1550-41c7-9701-2efe47fe1fd3', 'resourceVersion': '100'},
|
||||
'data': {'config.yaml': base64.b64encode(raw.encode()).decode(), 'key.pem': 'c3ludGhldGlj'}}
|
||||
|
||||
|
||||
def deployment():
|
||||
source = next(yaml.safe_load_all((PATH.parent / 'deployment.yaml').read_text()))
|
||||
source['metadata'].update(uid='99ddd83c-cb3f-4847-bcf8-35f1aa87627f', resourceVersion='101')
|
||||
# The helper is a one-shot rollout from PRIOR_IMAGE; deployment.yaml has
|
||||
# since advanced past it, so reduce the fixture to the pre-rollout state:
|
||||
# the prior image and none of the approval-client env the helper adds.
|
||||
container = next(c for c in source['spec']['template']['spec']['containers'] if c['name'] == 'keycape')
|
||||
container['image'] = m.PRIOR_IMAGE
|
||||
added = {x['name'] for x in yaml.safe_load(
|
||||
(m.KEYCAPE / 'docs/approval-clients-deployment.patch.yaml').read_text()
|
||||
)['spec']['template']['spec']['containers'][0]['env']}
|
||||
container['env'] = [x for x in container.get('env', []) if x['name'] not in added]
|
||||
return source
|
||||
|
||||
|
||||
class RolloutTests(unittest.TestCase):
|
||||
def test_append_preserves_original_bytes_and_signing_key(self):
|
||||
secret = fixture(); before = copy.deepcopy(secret)
|
||||
actual = base64.b64decode(m.replacement(secret, m.registrations())).decode()
|
||||
original = base64.b64decode(secret['data']['config.yaml']).decode()
|
||||
insertion_start = original.index('other:')
|
||||
self.assertTrue(actual.startswith(original[:insertion_start]))
|
||||
self.assertTrue(actual.endswith(original[insertion_start:]))
|
||||
self.assertEqual(secret, before)
|
||||
self.assertEqual(len(yaml.safe_load(actual)['clients']), 3)
|
||||
|
||||
def test_indentless_and_final_client_sequence(self):
|
||||
for text in ['authelia:\n issuer: https://auth.coulomb.social\nclients:\n- clientId: human\nother: keep\n',
|
||||
'authelia:\n issuer: https://auth.coulomb.social\nclients:\n - clientId: human']:
|
||||
parsed = yaml.safe_load(base64.b64decode(m.replacement(fixture(text), m.registrations())))
|
||||
self.assertEqual(len(parsed['clients']), 3)
|
||||
|
||||
def test_reject_duplicate_existing_registration(self):
|
||||
text = 'authelia:\n issuer: https://auth.coulomb.social\nclients:\n - clientId: secrets-engine-approval\n'
|
||||
with self.assertRaisesRegex(m.LaneError, 'existing_client_registration'):
|
||||
m.replacement(fixture(text), m.registrations())
|
||||
|
||||
def test_reject_ambiguous_yaml_and_wrong_issuer(self):
|
||||
for raw in ['authelia: {}\nauthelia: {}\nclients: []',
|
||||
'authelia:\n issuer: https://wrong.invalid\nclients:\n - clientId: human\n']:
|
||||
with self.assertRaises((m.LaneError, m.pin.IssuerPinError)):
|
||||
m.replacement(fixture(raw), m.registrations())
|
||||
|
||||
def test_candidate_preserves_existing_settings(self):
|
||||
old = deployment(); result = m.candidate_spec(old)
|
||||
before = old['spec']['template']['spec']['containers'][0]
|
||||
after = result['template']['spec']['containers'][0]
|
||||
self.assertEqual(result['strategy'], {'type': 'Recreate'})
|
||||
self.assertEqual(after['env'][:len(before['env'])], before['env'])
|
||||
for field in ['livenessProbe', 'resources', 'volumeMounts', 'startupProbe']:
|
||||
self.assertEqual(after[field], before[field])
|
||||
self.assertEqual(after['image'], m.IMAGE)
|
||||
|
||||
def test_candidate_refuses_current_declared_image(self):
|
||||
current = next(yaml.safe_load_all((PATH.parent / 'deployment.yaml').read_text()))
|
||||
with self.assertRaisesRegex(m.LaneError, 'prior_image_drift'):
|
||||
m.candidate_spec(current)
|
||||
|
||||
def test_cas_patch_contains_both_preconditions_and_stdin_only(self):
|
||||
with patch.object(m, 'command') as command:
|
||||
command.return_value.stdout = b'{}'
|
||||
m.patch_object(['kubectl'], 'secret', fixture(), '/data/config.yaml', 'synthetic-payload')
|
||||
args, kwargs = command.call_args
|
||||
self.assertNotIn('synthetic-payload', args[0])
|
||||
self.assertEqual([p['path'] for p in kwargs['payload'][:2]], ['/metadata/uid', '/metadata/resourceVersion'])
|
||||
|
||||
def test_failed_acceptance_restores_compatible_pair(self):
|
||||
state = {'secret': fixture(), 'deployment': deployment()}
|
||||
original = copy.deepcopy(state)
|
||||
def fake_patch(kube, kind, obj, path, value, dry=False):
|
||||
changed = copy.deepcopy(obj)
|
||||
if path == '/spec': changed['spec'] = value
|
||||
else: changed['data']['config.yaml'] = value
|
||||
if not dry:
|
||||
changed['metadata']['resourceVersion'] = str(int(changed['metadata']['resourceVersion']) + 1)
|
||||
state[kind] = changed
|
||||
return copy.deepcopy(changed)
|
||||
with tempfile.TemporaryDirectory() as directory, patch.object(m, 'assert_cluster'), patch.object(m, 'verify_artifact'), \
|
||||
patch.object(m, 'get', side_effect=lambda kube, kind, name: copy.deepcopy(state[kind])), \
|
||||
patch.object(m, 'patch_object', side_effect=fake_patch), patch.object(m, 'ready', return_value={}), \
|
||||
patch.object(m, 'acceptance', side_effect=m.LaneError('synthetic_acceptance_failure')):
|
||||
receipt = {}
|
||||
with self.assertRaisesRegex(m.LaneError, 'synthetic_acceptance_failure'):
|
||||
m.rollout([], receipt, Path(directory) / 'recovery.json')
|
||||
self.assertTrue(receipt['compatible_pair_restored'])
|
||||
self.assertEqual(state['secret']['data'], original['secret']['data'])
|
||||
self.assertEqual(state['deployment']['spec'], original['deployment']['spec'])
|
||||
self.assertEqual((Path(directory) / 'recovery.json').stat().st_mode & 0o777, 0o600)
|
||||
|
||||
def test_ready_matches_manifest_imageid_not_runtime_config_id(self):
|
||||
dep = deployment(); dep['metadata']['generation'] = 30
|
||||
dep['status'] = {'observedGeneration': 30, 'updatedReplicas': 1, 'readyReplicas': 1, 'availableReplicas': 1, 'replicas': 1}
|
||||
pod = {'metadata': {'uid': 'fixture-pod'}, 'status': {'containerStatuses': [
|
||||
{'name': 'keycape', 'ready': True, 'image': 'sha256:runtime-config-id', 'imageID': m.IMAGE}]}}
|
||||
from types import SimpleNamespace
|
||||
with patch.object(m, 'get', return_value=dep), patch.object(m, 'command', return_value=SimpleNamespace(stdout=json.dumps({'items': [pod]}).encode())):
|
||||
self.assertTrue(m.ready([], m.IMAGE, timeout=1)['single_ready_replica'])
|
||||
|
||||
def test_iat_skew_matches_native_contract_without_extending_expiry(self):
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
now = int(m.time.time())
|
||||
claims = {'iss': m.ISSUER, 'aud': 'approval-engine', 'sub': 'synthetic-service', 'iat': now + 2, 'exp': now + 902}
|
||||
encoded = m.jwt.encode(claims, key, algorithm='RS256')
|
||||
self.assertEqual(m.verified_claims(encoded, key.public_key())['iat'], now + 2)
|
||||
future = m.jwt.encode(dict(claims, iat=now+60), key, algorithm='RS256')
|
||||
with self.assertRaisesRegex(m.LaneError, 'issued_at_binding_failed'):
|
||||
m.verified_claims(future, key.public_key())
|
||||
expired = m.jwt.encode(dict(claims, iat=now-900, exp=now-1), key, algorithm='RS256')
|
||||
with self.assertRaises(m.jwt.ExpiredSignatureError):
|
||||
m.verified_claims(expired, key.public_key())
|
||||
|
||||
def test_unrelated_refusal_never_passes(self):
|
||||
for status, body in [(500, {}), (400, {'error': 'invalid_profile_usage', 'feature': 'client_id'}),
|
||||
(401, {'error': 'invalid_profile_usage', 'feature': 'scope'})]:
|
||||
with self.assertRaises(m.LaneError):
|
||||
m.denied((status, body), 400, 'scope')
|
||||
|
||||
if __name__ == '__main__': unittest.main()
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
import base64,importlib.util,json,unittest
|
||||
from pathlib import Path
|
||||
import yaml
|
||||
spec=importlib.util.spec_from_file_location('rollout',Path(__file__).with_name('authentication-policy-rollout.py'))
|
||||
module=importlib.util.module_from_spec(spec);spec.loader.exec_module(module)
|
||||
class PolicyMigration(unittest.TestCase):
|
||||
def secret(self):
|
||||
raw='''# exact bytes outside reviewed fields must survive
|
||||
issuer: https://fixture.test
|
||||
authelia:
|
||||
issuer: https://primary.test
|
||||
clients:
|
||||
- clientId: user-engine-portal
|
||||
clientType: public
|
||||
grantTypes: [authorization_code]
|
||||
- clientId: untouched
|
||||
mfaRequired: true
|
||||
clientSecret: fixture-secret
|
||||
- clientId: vergabe-demo-company
|
||||
clientType: public
|
||||
grantTypes: [authorization_code]
|
||||
privacyidea:
|
||||
requireForAll: true
|
||||
'''
|
||||
return {'data':{'config.yaml':base64.b64encode(raw.encode()).decode(),'key.pem':'fixture-key'}}
|
||||
def test_exact_migration_preserves_siblings_and_retries(self):
|
||||
secret=self.secret();value,changed=module.replacement(secret);self.assertTrue(changed)
|
||||
raw=base64.b64decode(value).decode();self.assertIn('# exact bytes',raw);self.assertIn(' clientSecret: fixture-secret\n',raw)
|
||||
config=yaml.safe_load(raw);self.assertTrue(config['privacyidea']['requireForAll'])
|
||||
self.assertTrue(config['clients'][0]['mfaOptional']);self.assertTrue(config['clients'][2]['mfaOptional'])
|
||||
secret['data']['config.yaml']=value;again,changed=module.replacement(secret);self.assertFalse(changed);self.assertEqual(value,again)
|
||||
def test_explicit_or_duplicate_registration_refused(self):
|
||||
for edit in [lambda s:s.replace(' clientType: public',' mfaRequired: false\n clientType: public',1),lambda s:s.replace('clientId: untouched','clientId: user-engine-portal')]:
|
||||
secret=self.secret();secret['data']['config.yaml']=base64.b64encode(edit(base64.b64decode(secret['data']['config.yaml']).decode()).encode()).decode()
|
||||
with self.assertRaises(module.Refused):module.replacement(secret)
|
||||
|
|
@ -0,0 +1,115 @@
|
|||
"""Issuer-only mutation, concurrency and output-containment regression checks."""
|
||||
import argparse
|
||||
import base64
|
||||
import copy
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import subprocess
|
||||
import unittest
|
||||
from contextlib import redirect_stdout, redirect_stderr
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
spec = importlib.util.spec_from_file_location('owner_config', Path(__file__).with_name('openbao-client-config.py'))
|
||||
m = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(m)
|
||||
UID = '2e94519d-1550-41c7-9701-2efe47fe1fd3'
|
||||
SENTINEL = 'DO-NOT-EMIT-fixture-private-value'
|
||||
RAW = '# retained comment\nauthelia:\n baseURL: https://auth.coulomb.social\n clientSecret: '+SENTINEL+'\nclients:\n - clientId: existing\n secretRef: env:EXISTING\n'
|
||||
|
||||
|
||||
def secret(raw=RAW):
|
||||
return {'kind': 'Secret', 'metadata': {'name': 'keycape-config', 'namespace': 'sso', 'uid': UID, 'resourceVersion': '12'},
|
||||
'data': {'config.yaml': base64.b64encode(raw.encode()).decode(), 'key.pem': base64.b64encode(SENTINEL.encode()).decode()}}
|
||||
|
||||
|
||||
class IssuerPinTest(unittest.TestCase):
|
||||
def test_add_preserves_every_other_byte(self):
|
||||
encoded, changed, prior = m.issuer_replacement(secret())
|
||||
updated = base64.b64decode(encoded).decode()
|
||||
self.assertTrue(changed)
|
||||
self.assertEqual(prior, 'absent')
|
||||
self.assertEqual(updated.replace(' issuer: "https://auth.coulomb.social"\n', ''), RAW)
|
||||
|
||||
def test_replace_retains_comments_and_other_fields(self):
|
||||
raw = RAW.replace('authelia:\n', 'authelia:\n issuer: "http://old.example" # keep\n')
|
||||
encoded, changed, prior = m.issuer_replacement(secret(raw))
|
||||
self.assertTrue(changed)
|
||||
self.assertEqual(prior, 'different')
|
||||
self.assertEqual(base64.b64decode(encoded).decode(), raw.replace('"http://old.example"', '"https://auth.coulomb.social"'))
|
||||
|
||||
def test_matching_is_byte_identical_noop(self):
|
||||
raw = RAW.replace('authelia:\n', 'authelia:\n issuer: https://auth.coulomb.social\n')
|
||||
source = secret(raw)
|
||||
self.assertEqual(m.issuer_replacement(source), (source['data']['config.yaml'], False, 'matching'))
|
||||
|
||||
def test_ambiguous_documents_and_aliases_are_refused(self):
|
||||
for raw in [RAW+'authelia: {}\n', RAW.replace(' baseURL:', ' issuer: a\n issuer: b\n baseURL:'),
|
||||
'authelia: {issuer: old}\n', RAW+'---\nauthelia: {}\n', RAW+'alias: &shared value\ncopy: *shared\n']:
|
||||
with self.subTest(raw=raw), self.assertRaises(Exception):
|
||||
m.issuer_replacement(secret(raw))
|
||||
|
||||
def test_check_never_patches_or_reports_old_value(self):
|
||||
source = secret(RAW.replace('authelia:\n', 'authelia:\n issuer: '+SENTINEL+'\n'))
|
||||
with patch.object(m, 'contained_kube', return_value=source) as kube:
|
||||
result = m.issuer_live('issuer-check-live')
|
||||
self.assertEqual(kube.call_count, 1)
|
||||
self.assertFalse(result['issuer_matches'])
|
||||
self.assertNotIn(SENTINEL, json.dumps(result))
|
||||
|
||||
def test_pin_checks_observed_revision_before_mutation(self):
|
||||
with patch.object(m, 'contained_kube', return_value=secret()) as kube:
|
||||
with self.assertRaisesRegex(m.IssuerPinError, 'observed_revision_changed'):
|
||||
m.issuer_live('issuer-pin-live', UID, '11')
|
||||
self.assertEqual(kube.call_count, 1)
|
||||
|
||||
def test_pin_uses_cas_and_proves_other_secret_data_unchanged(self):
|
||||
state = secret()
|
||||
calls = []
|
||||
def kube(args, payload=None):
|
||||
calls.append((args, payload))
|
||||
if args[0] == 'patch':
|
||||
self.assertEqual(payload[:2], [{'op': 'test', 'path': '/metadata/uid', 'value': UID},
|
||||
{'op': 'test', 'path': '/metadata/resourceVersion', 'value': '12'}])
|
||||
self.assertEqual(payload[2]['path'], '/data/config.yaml')
|
||||
state['data']['config.yaml'] = payload[2]['value']
|
||||
state['metadata']['resourceVersion'] = '13'
|
||||
return copy.deepcopy(state)
|
||||
with patch.object(m, 'contained_kube', side_effect=kube):
|
||||
result = m.issuer_live('issuer-pin-live', UID, '12')
|
||||
self.assertEqual(len(calls), 3)
|
||||
self.assertTrue(result['issuer_matches'])
|
||||
self.assertTrue(result['other_secret_data_unchanged'])
|
||||
self.assertNotIn(SENTINEL, json.dumps(result))
|
||||
|
||||
def test_readback_race_is_not_retried_or_overwritten(self):
|
||||
after = secret()
|
||||
after['data']['key.pem'] = 'changed-by-another-actor'
|
||||
with patch.object(m, 'contained_kube', side_effect=[secret(), {}, after]) as kube:
|
||||
with self.assertRaisesRegex(m.IssuerPinError, 'readback_mismatch'):
|
||||
m.issuer_live('issuer-pin-live', UID, '12')
|
||||
self.assertEqual(kube.call_count, 3)
|
||||
|
||||
def test_subprocess_error_output_and_parser_errors_stay_contained(self):
|
||||
args = argparse.Namespace(mode='issuer-check-live', expected_uid=None, expected_resource_version=None)
|
||||
failures = [ValueError(SENTINEL), subprocess.TimeoutExpired(['kubectl'], 30, output=SENTINEL)]
|
||||
for failure in failures:
|
||||
output = io.StringIO()
|
||||
with patch.object(m, 'contained_kube', side_effect=failure), redirect_stdout(output), redirect_stderr(output):
|
||||
self.assertEqual(m.issuer_main(args), 1)
|
||||
self.assertNotIn(SENTINEL, output.getvalue())
|
||||
self.assertEqual(json.loads(output.getvalue())['reason'], 'contained_operation_failed')
|
||||
|
||||
def test_patch_payload_only_goes_to_captured_child_stdin(self):
|
||||
with patch.object(m.subprocess, 'run', return_value=subprocess.CompletedProcess([], 1, SENTINEL, SENTINEL)) as run:
|
||||
with self.assertRaisesRegex(m.IssuerPinError, '^kubernetes_operation_failed$'):
|
||||
m.contained_kube(['patch', 'secret', 'keycape-config'], {'data': SENTINEL})
|
||||
args, kwargs = run.call_args
|
||||
self.assertNotIn(SENTINEL, json.dumps(args))
|
||||
self.assertIn(SENTINEL, kwargs['input'])
|
||||
self.assertTrue(kwargs['capture_output'])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
"""Opt-in real-checker boundary regressions; no production inputs or checker stub.
|
||||
|
||||
Set KEYCAPE_TEST_CHECKER_BINARY and KEYCAPE_TEST_CHECKER_SHA256 to an independently
|
||||
built artifact. Ordinary helper-only runs skip this class when none is supplied.
|
||||
"""
|
||||
from contextlib import chdir
|
||||
import hashlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from test_keycape_client import kc
|
||||
|
||||
VALID = '''issuer: https://issuer.example.invalid
|
||||
port: 8080
|
||||
privateKeyPem: /nonexistent/synthetic-key.pem
|
||||
clients:
|
||||
- clientId: synthetic-client
|
||||
displayName: Synthetic client
|
||||
redirectUris: [https://client.example.invalid/cb]
|
||||
allowedScopes: [openid]
|
||||
grantTypes: [authorization_code]
|
||||
clientType: public
|
||||
'''
|
||||
|
||||
|
||||
class RealCheckerEnvironmentTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
source = os.environ.get('KEYCAPE_TEST_CHECKER_BINARY')
|
||||
if not source:
|
||||
raise unittest.SkipTest('exact real checker artifact not supplied')
|
||||
data = Path(source).read_bytes()
|
||||
expected = os.environ.get('KEYCAPE_TEST_CHECKER_SHA256')
|
||||
if expected != 'sha256:' + hashlib.sha256(data).hexdigest():
|
||||
raise ValueError('real checker artifact digest differs')
|
||||
cls.directory = tempfile.TemporaryDirectory()
|
||||
cls.addClassCleanup(cls.directory.cleanup)
|
||||
cls.binary = Path(cls.directory.name) / 'keycape'
|
||||
cls.binary.write_bytes(data)
|
||||
cls.binary.chmod(0o700)
|
||||
|
||||
def check(self, raw, binary=None):
|
||||
return kc.check_config(str(binary or self.binary), raw, [], False)
|
||||
|
||||
def test_ambient_overrides_cannot_reject_supplied_valid_config(self):
|
||||
with patch.dict(os.environ, {'KEYCAPE_ACCOUNT_PORTAL_URL': 'http://polluted.example.invalid',
|
||||
'KEYCAPE_BROWSER_LOGOUT_URL': 'http://polluted.example.invalid/logout'}):
|
||||
self.assertTrue(self.check(VALID)['valid'])
|
||||
|
||||
def test_ambient_overrides_cannot_admit_supplied_invalid_config(self):
|
||||
invalid = VALID + 'accountPortalURL: http://invalid.example.invalid\nbrowserLogoutURL: http://invalid.example.invalid/logout\n'
|
||||
with patch.dict(os.environ, {'KEYCAPE_ACCOUNT_PORTAL_URL': 'https://polluted.example.invalid',
|
||||
'KEYCAPE_BROWSER_LOGOUT_URL': 'https://polluted.example.invalid/logout'}):
|
||||
self.assertFalse(self.check(invalid)['valid'])
|
||||
|
||||
def test_relative_selected_artifact_is_not_replaced_by_path_decoy(self):
|
||||
decoy = Path(self.directory.name) / 'decoy'
|
||||
decoy.mkdir()
|
||||
trap = decoy / 'keycape'
|
||||
trap.write_text('#!/bin/sh\nexit 27\n')
|
||||
trap.chmod(0o700)
|
||||
with chdir(self.directory.name), patch.dict(os.environ, {'PATH': str(decoy)}):
|
||||
self.assertTrue(self.check(VALID, binary=Path('keycape'))['valid'])
|
||||
|
|
@ -0,0 +1,122 @@
|
|||
"""Linux executable-byte custody fixtures; no credentials or native authority."""
|
||||
import fcntl
|
||||
import hashlib
|
||||
import mmap
|
||||
import os
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from test_keycape_registration_adapter import a
|
||||
from test_keycape_checker_environment import VALID
|
||||
|
||||
|
||||
class SnapshotTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.source = Path(self.temp.name) / 'checker'
|
||||
self.source.write_bytes(b'synthetic artifact')
|
||||
self.expected = a.digest(self.source.read_bytes())
|
||||
|
||||
def test_snapshot_bytes_sealed_and_descriptor_closed_on_exit(self):
|
||||
with a.checker_snapshot(self.source, self.expected) as fd:
|
||||
self.assertEqual(Path(f'/proc/self/fd/{fd}').read_bytes(), self.source.read_bytes())
|
||||
for action in (lambda: os.write(fd, b'x'), lambda: os.ftruncate(fd, 0),
|
||||
lambda: os.ftruncate(fd, 128)):
|
||||
with self.assertRaises(OSError):
|
||||
action()
|
||||
# Even if this process restores write permission, seals forbid writes.
|
||||
os.fchmod(fd, 0o700)
|
||||
reopened = os.open(f'/proc/self/fd/{fd}', os.O_RDWR)
|
||||
try:
|
||||
with self.assertRaises(OSError):
|
||||
os.write(reopened, b'x')
|
||||
with self.assertRaises(OSError):
|
||||
mmap.mmap(reopened, len(b'synthetic artifact'), access=mmap.ACCESS_WRITE)
|
||||
finally:
|
||||
os.close(reopened)
|
||||
with self.assertRaises(OSError):
|
||||
os.fstat(fd)
|
||||
|
||||
def test_hash_mismatch_refused_before_memfd(self):
|
||||
self.source.write_bytes(b'changed')
|
||||
with patch.object(a.os, 'memfd_create') as create:
|
||||
with self.assertRaises(a.Refused):
|
||||
with a.checker_snapshot(self.source, self.expected):
|
||||
self.fail('unexpected snapshot')
|
||||
create.assert_not_called()
|
||||
|
||||
def test_source_symlink_refused(self):
|
||||
link = self.source.with_name('link')
|
||||
link.symlink_to(self.source)
|
||||
with self.assertRaises(OSError):
|
||||
with a.checker_snapshot(link, self.expected):
|
||||
self.fail('unexpected snapshot')
|
||||
|
||||
def test_fifo_refused_without_blocking(self):
|
||||
fifo = self.source.with_name('fifo')
|
||||
os.mkfifo(fifo)
|
||||
with self.assertRaises(a.Refused):
|
||||
with a.checker_snapshot(fifo, self.expected):
|
||||
self.fail('unexpected snapshot')
|
||||
|
||||
def test_sealing_failure_closes_snapshot(self):
|
||||
original = a.os.memfd_create
|
||||
opened = []
|
||||
def create(*args):
|
||||
fd = original(*args)
|
||||
opened.append(fd)
|
||||
return fd
|
||||
with patch.object(a.os, 'memfd_create', side_effect=create), patch.object(a.fcntl, 'fcntl', side_effect=OSError('synthetic failure')):
|
||||
with self.assertRaises(OSError):
|
||||
with a.checker_snapshot(self.source, self.expected):
|
||||
self.fail('unexpected snapshot')
|
||||
with self.assertRaises(OSError):
|
||||
os.fstat(opened[0])
|
||||
|
||||
def test_seal_readback_mismatch_refused(self):
|
||||
original = a.fcntl.fcntl
|
||||
def invoke(fd, command, *args):
|
||||
return 0 if command == fcntl.F_GET_SEALS else original(fd, command, *args)
|
||||
with patch.object(a.fcntl, 'fcntl', side_effect=invoke):
|
||||
with self.assertRaises(a.Refused):
|
||||
with a.checker_snapshot(self.source, self.expected):
|
||||
self.fail('unexpected snapshot')
|
||||
|
||||
|
||||
class RealSnapshotTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
source = os.environ.get('KEYCAPE_TEST_CHECKER_BINARY')
|
||||
if not source:
|
||||
raise unittest.SkipTest('exact real checker artifact not supplied')
|
||||
cls.data = Path(source).read_bytes()
|
||||
cls.expected = os.environ.get('KEYCAPE_TEST_CHECKER_SHA256')
|
||||
if cls.expected != 'sha256:' + hashlib.sha256(cls.data).hexdigest():
|
||||
raise ValueError('real checker artifact digest differs')
|
||||
|
||||
def test_real_checker_executes_pinned_snapshot_after_selected_path_replaced(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
source = Path(directory) / 'keycape'
|
||||
source.write_bytes(self.data)
|
||||
source.chmod(0o700)
|
||||
with a.checker_snapshot(source, self.expected) as fd:
|
||||
replacement = source.with_name('replacement')
|
||||
replacement.write_text('#!/bin/sh\nexit 27\n')
|
||||
replacement.chmod(0o700)
|
||||
replacement.replace(source)
|
||||
result = a.helper.check_config(source, VALID, [], False, executable_fd=fd)
|
||||
self.assertTrue(result['valid'])
|
||||
invalid = VALID + 'accountPortalURL: http://invalid.example.invalid\n'
|
||||
self.assertFalse(a.helper.check_config(source, invalid, [], False, executable_fd=fd)['valid'])
|
||||
|
||||
def test_real_checker_ignores_in_place_source_overwrite_after_snapshot(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
source = Path(directory) / 'keycape'
|
||||
source.write_bytes(self.data)
|
||||
with a.checker_snapshot(source, self.expected) as fd:
|
||||
source.write_bytes(b'no executable remains at the selected path')
|
||||
with patch.dict(os.environ, {'KEYCAPE_ACCOUNT_PORTAL_URL': 'http://polluted.example.invalid'}):
|
||||
self.assertTrue(a.helper.check_config(source, VALID, [], False, executable_fd=fd)['valid'])
|
||||
|
|
@ -0,0 +1,317 @@
|
|||
"""Actual helper/CAS execution against synthetic Kubernetes objects only."""
|
||||
from dataclasses import asdict, replace
|
||||
import copy
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from warden.case_run import Invocation, digest
|
||||
from warden.case_receipt_state import Outcome, Pins, Refused
|
||||
import yaml
|
||||
|
||||
ROOT = Path(__file__).parent
|
||||
spec = importlib.util.spec_from_file_location('registration_adapter', ROOT / 'keycape-registration-adapter.py')
|
||||
a = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(a)
|
||||
spec = importlib.util.spec_from_file_location('registration_fixture', ROOT / 'test_keycape_client.py')
|
||||
f = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(f)
|
||||
|
||||
|
||||
class RegistrationTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.binary = Path(self.temp.name) / 'keycape'
|
||||
self.binary.write_bytes(b'synthetic checker binary')
|
||||
self.case = yaml.safe_dump({'spec': {'type': 'oidc-browser-relying-party@1.1',
|
||||
'parameters': f.FORGEJO_PARAMS}}).encode()
|
||||
desc = a.descriptor(digest(self.binary.read_bytes()))
|
||||
self.desc_bytes = json.dumps(asdict(desc), sort_keys=True, separators=(',', ':')).encode()
|
||||
pins = Pins(digest(self.case), digest(b'type'), digest(self.desc_bytes),
|
||||
digest(b'catalog'), digest(b'case-acceptance:test'), digest(b'run sheet'))
|
||||
self.admitted = Invocation('register-client', 'net-kingdom', 'forgejo', 'human',
|
||||
'founder', 'key-cape-oidc-login', pins)
|
||||
self.inv = replace(self.admitted, attempt_id='attempt-one', operation='run')
|
||||
self.secret, self.raw = f.fixture()
|
||||
self.effects = []
|
||||
self.evidence = {}
|
||||
self.revocations = []
|
||||
self.kw = dict(admitted=self.admitted, case_source=self.case,
|
||||
expected_uid=f.UID, expected_resource_version='7', case_acceptance='case-acceptance:test',
|
||||
check_config=self.binary, check_config_digest=digest(self.binary.read_bytes()),
|
||||
publish=self.publish, authenticate=lambda ref, payload: self.evidence.get(ref) == payload,
|
||||
revoke_session=self.revoke)
|
||||
self.check_patch = patch.object(a.helper, 'check_config', return_value={'valid': True})
|
||||
self.kube_patch = patch.object(a.helper.lane, 'kubectl', side_effect=self.kube)
|
||||
self.check_patch.start()
|
||||
self.kube_patch.start()
|
||||
self.addCleanup(self.check_patch.stop)
|
||||
self.addCleanup(self.kube_patch.stop)
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
|
||||
def publish(self, payload):
|
||||
ref = 'owner-evidence:attempt-' + str(len(self.evidence))
|
||||
self.evidence[ref] = payload
|
||||
return ref
|
||||
|
||||
def revoke(self, inv):
|
||||
self.revocations.append(inv)
|
||||
return 'session-revoked:' + inv.operation + ':' + inv.attempt_id
|
||||
|
||||
def kube(self, args, payload=None):
|
||||
if args[:3] == ['get', 'namespace', 'kube-system']:
|
||||
return {'metadata': {'uid': a.helper.lane.CLUSTER_UID}}
|
||||
if args[:5] == ['-n', 'sso', 'get', 'secret', 'keycape-config']:
|
||||
return copy.deepcopy(self.secret)
|
||||
if args[:5] == ['-n', 'sso', 'get', 'deployment', 'keycape']:
|
||||
return {'spec': {'template': {'spec': {'containers': [{'name': 'keycape',
|
||||
'env': [a.helper.env_binding(f.FORGEJO)]}]}}}}
|
||||
if args[:5] == ['-n', 'sso', 'patch', 'secret', 'keycape-config']:
|
||||
self.assertEqual(payload[:2], [
|
||||
{'op': 'test', 'path': '/metadata/uid', 'value': self.secret['metadata']['uid']},
|
||||
{'op': 'test', 'path': '/metadata/resourceVersion', 'value': self.secret['metadata']['resourceVersion']}])
|
||||
self.effects.append(copy.deepcopy(payload))
|
||||
self.secret['data']['config.yaml'] = payload[2]['value']
|
||||
self.secret['metadata']['resourceVersion'] = str(int(self.secret['metadata']['resourceVersion']) + 1)
|
||||
return copy.deepcopy(self.secret)
|
||||
raise AssertionError('unexpected tooling operation')
|
||||
|
||||
def test_actual_add_and_created_only_rollback_preserve_original_bytes(self):
|
||||
adapter = a.build(**self.kw)
|
||||
original = copy.deepcopy(self.secret['data'])
|
||||
result = adapter.invoke(self.inv)
|
||||
self.assertEqual(result.outcome, Outcome.SUCCESS)
|
||||
self.assertEqual(result.created, ('keycape-client',))
|
||||
self.assertTrue(adapter.verify(self.inv, result))
|
||||
undone = adapter.rollback(replace(self.inv, attempt_id='attempt-two', operation='rollback'), result.created)
|
||||
self.assertEqual(undone.outcome, Outcome.ROLLED_BACK)
|
||||
self.assertEqual(self.secret['data'], original)
|
||||
self.assertEqual(len(self.effects), 2)
|
||||
self.assertEqual(len(self.revocations), 2)
|
||||
for payload in self.evidence.values():
|
||||
self.assertNotIn(b'synthetic-marker', payload)
|
||||
self.assertNotIn(b'config.yaml', payload)
|
||||
self.assertNotIn(b'signing-key', payload)
|
||||
|
||||
def test_adapter_refuses_symlink_fifo_and_oversized_source_before_helper(self):
|
||||
symlink = self.binary.with_name('symlink')
|
||||
symlink.symlink_to(self.binary)
|
||||
fifo = self.binary.with_name('fifo')
|
||||
os.mkfifo(fifo)
|
||||
oversized = self.binary.with_name('oversized')
|
||||
with oversized.open('wb') as output:
|
||||
output.truncate(64 * 1024 * 1024 + 1)
|
||||
for source in (symlink, fifo, oversized):
|
||||
with self.subTest(source=source.name), patch.object(a.helper, 'run') as run:
|
||||
adapter = a.build(**dict(self.kw, check_config=source))
|
||||
with self.assertRaises(Refused):
|
||||
adapter.invoke(self.inv)
|
||||
run.assert_not_called()
|
||||
self.assertEqual(self.revocations, [self.inv] * 3)
|
||||
self.assertFalse(self.effects)
|
||||
self.assertFalse(self.evidence)
|
||||
|
||||
def test_snapshot_unavailable_refuses_before_tooling_and_revokes_session(self):
|
||||
adapter = a.build(**self.kw)
|
||||
with patch.object(a.os, 'memfd_create', side_effect=OSError('synthetic policy denial')):
|
||||
with self.assertRaises(Refused):
|
||||
adapter.invoke(self.inv)
|
||||
self.assertFalse(self.effects)
|
||||
self.assertFalse(self.evidence)
|
||||
self.assertEqual(self.revocations, [self.inv])
|
||||
|
||||
def test_identical_client_is_no_change_and_cannot_be_removed(self):
|
||||
self.secret, _ = f.fixture(f.EXISTING + [f.FORGEJO])
|
||||
adapter = a.build(**self.kw)
|
||||
result = adapter.invoke(self.inv)
|
||||
self.assertEqual(result.outcome, Outcome.NO_CHANGE)
|
||||
self.assertEqual(result.created, ())
|
||||
with self.assertRaises(Refused):
|
||||
adapter.rollback(replace(self.inv, operation='rollback'), ('keycape-client',))
|
||||
self.assertFalse(self.effects)
|
||||
|
||||
def test_existing_drift_refused_and_session_revoked(self):
|
||||
self.secret, _ = f.fixture(f.EXISTING + [dict(f.FORGEJO, displayName='Drift')])
|
||||
with self.assertRaises(Refused):
|
||||
a.build(**self.kw).invoke(self.inv)
|
||||
self.assertFalse(self.effects)
|
||||
self.assertEqual(self.revocations, [self.inv])
|
||||
|
||||
def test_invocation_rebinding_refused_before_tooling(self):
|
||||
for changes in ({'target': 'other'}, {'principal': 'other'}, {'actor': 'agent'},
|
||||
{'lane': 'other'}, {'operation': 'rollback'}, {'attempt_id': ''},
|
||||
{'pins': replace(self.inv.pins, case=digest(b'other'))}):
|
||||
with self.subTest(changes=changes), self.assertRaises(Refused):
|
||||
a.build(**self.kw).invoke(replace(self.inv, **changes))
|
||||
self.assertFalse(self.effects)
|
||||
|
||||
def test_initial_case_descriptor_and_target_pins_are_required(self):
|
||||
for changes in ({'case_source': b'{}'}, {'case_acceptance': 'other'}, {'admitted': replace(self.admitted, target='other')},
|
||||
{'admitted': replace(self.admitted, actor='agent')},
|
||||
{'admitted': replace(self.admitted, pins=replace(self.admitted.pins, adapter=digest(b'wrong')))}):
|
||||
with self.subTest(changes=changes), self.assertRaises(Refused):
|
||||
a.build(**dict(self.kw, **changes))
|
||||
self.assertFalse(self.effects)
|
||||
|
||||
def test_binary_or_source_drift_refused_before_tooling(self):
|
||||
adapter = a.build(**self.kw)
|
||||
self.binary.write_bytes(b'changed')
|
||||
with self.assertRaises(Refused):
|
||||
adapter.invoke(self.inv)
|
||||
self.binary.write_bytes(b'synthetic checker binary')
|
||||
with patch.object(a, 'source_digest', return_value=digest(b'changed')):
|
||||
with self.assertRaises(Refused):
|
||||
adapter.invoke(self.inv)
|
||||
self.assertFalse(self.effects)
|
||||
|
||||
def test_substituting_caller_selected_checker_under_same_descriptor_is_refused(self):
|
||||
substitute = Path(self.temp.name) / 'other-checker'
|
||||
substitute.write_bytes(b'caller-selected executable')
|
||||
with self.assertRaisesRegex(Refused, 'descriptor pin differs'):
|
||||
a.build(**dict(self.kw, check_config=substitute,
|
||||
check_config_digest=digest(substitute.read_bytes())))
|
||||
self.assertFalse(self.effects)
|
||||
|
||||
def test_stale_revision_refuses_mutation(self):
|
||||
adapter = a.build(**self.kw)
|
||||
self.secret['metadata']['resourceVersion'] = '8'
|
||||
with self.assertRaises(Refused):
|
||||
adapter.invoke(self.inv)
|
||||
self.assertFalse(self.effects)
|
||||
|
||||
def test_rollback_requires_exact_provenance_and_postcreation_revision(self):
|
||||
adapter = a.build(**self.kw)
|
||||
result = adapter.invoke(self.inv)
|
||||
rollback = replace(self.inv, attempt_id='attempt-two', operation='rollback')
|
||||
for created in ((), ('other',), ('keycape-client', 'other')):
|
||||
with self.subTest(created=created), self.assertRaises(Refused):
|
||||
adapter.rollback(rollback, created)
|
||||
self.secret['metadata']['resourceVersion'] = '9'
|
||||
with self.assertRaises(Refused):
|
||||
adapter.rollback(rollback, result.created)
|
||||
self.assertEqual(len(self.effects), 1)
|
||||
|
||||
def test_missing_or_tampered_evidence_never_verifies_or_authorizes_undo(self):
|
||||
adapter = a.build(**self.kw)
|
||||
result = adapter.invoke(self.inv)
|
||||
self.assertFalse(adapter.verify(replace(self.inv, attempt_id='other'), result))
|
||||
self.assertFalse(adapter.verify(self.inv, replace(result, revoked=False)))
|
||||
self.evidence[result.evidence] = b'forged'
|
||||
self.assertFalse(adapter.verify(self.inv, result))
|
||||
with self.assertRaises(Refused):
|
||||
adapter.rollback(replace(self.inv, operation='rollback'), result.created)
|
||||
self.assertEqual(len(self.effects), 1)
|
||||
|
||||
def test_process_loss_does_not_reconstruct_created_authority(self):
|
||||
result = a.build(**self.kw).invoke(self.inv)
|
||||
fresh = a.build(**self.kw)
|
||||
with self.assertRaises(Refused):
|
||||
fresh.rollback(replace(self.inv, operation='rollback'), result.created)
|
||||
self.assertIsNone(fresh.reconcile)
|
||||
self.assertEqual(len(self.effects), 1)
|
||||
|
||||
def test_failed_readback_is_uncertain_and_never_provides_creation(self):
|
||||
original_kube = self.kube
|
||||
def lost(args, payload=None):
|
||||
if self.effects and args[:5] == ['-n', 'sso', 'get', 'secret', 'keycape-config']:
|
||||
raise RuntimeError('synthetic sensitive detail')
|
||||
return original_kube(args, payload)
|
||||
with patch.object(a.helper.lane, 'kubectl', side_effect=lost):
|
||||
adapter = a.build(**self.kw)
|
||||
with self.assertRaisesRegex(Refused, '^owner callback failed$'):
|
||||
adapter.invoke(self.inv)
|
||||
self.assertEqual(len(self.effects), 1)
|
||||
self.assertFalse(self.evidence)
|
||||
self.assertEqual(self.revocations, [self.inv])
|
||||
|
||||
def test_revocation_or_publication_failure_cannot_produce_verified_success(self):
|
||||
for capability in ('publish', 'revoke_session'):
|
||||
self.secret, _ = f.fixture()
|
||||
with self.subTest(capability=capability):
|
||||
kw = dict(self.kw, **{capability: lambda *args: (_ for _ in ()).throw(RuntimeError('private'))})
|
||||
adapter = a.build(**kw)
|
||||
with self.assertRaisesRegex(Refused, '^owner callback failed$'):
|
||||
adapter.invoke(self.inv)
|
||||
self.assertFalse(self.evidence)
|
||||
|
||||
def kernel_fixture(self):
|
||||
# Fixture-only proposed type/case; canonical files are never edited.
|
||||
# Warden's published producer pins apply unchanged. Owner trust and
|
||||
# proposed descriptor/type/case are fixture-only, never native admission.
|
||||
import warden.case_inputs as inputs
|
||||
from warden.case_run import Executor, Journal
|
||||
root = ROOT.parents[2]
|
||||
case = yaml.safe_load((root / 'security-cases/forgejo-netkingdom-oidc.yaml').read_bytes())
|
||||
case['spec']['status'] = 'materialized'
|
||||
case_source = yaml.safe_dump(case).encode()
|
||||
typ = yaml.safe_load((root / 'security-case-types/oidc-browser-relying-party/type.yaml').read_bytes())
|
||||
typ['spec']['execution_descriptors'] = {'register-client': digest(self.desc_bytes)}
|
||||
type_source = yaml.safe_dump(typ).encode()
|
||||
catalog = b'fixture-only-catalog'
|
||||
sheet = json.dumps(['register-client', 'net-kingdom', 'forgejo', 'human',
|
||||
'founder', 'key-cape-oidc-login', 'fixture-acceptance', 'fixture-materialization',
|
||||
'fixture-descriptor', 'fixture-session'], separators=(',', ':')).encode()
|
||||
pins = Pins(digest(case_source), digest(type_source), digest(self.desc_bytes),
|
||||
digest(catalog), digest(b'fixture-acceptance'), digest(sheet))
|
||||
invocation = replace(self.admitted, pins=pins)
|
||||
adapter = a.build(**dict(self.kw, admitted=invocation, case_source=case_source,
|
||||
case_acceptance='fixture-acceptance'))
|
||||
def fixture_verifier(purpose, source, evidence):
|
||||
if purpose.startswith('session-revocation:'):
|
||||
_, lane, operation, attempt = purpose.split(':')
|
||||
return source == digest(case_source) and lane == invocation.lane and evidence == (
|
||||
'session-revoked:' + operation + ':' + attempt)
|
||||
return evidence in {'fixture-acceptance', 'fixture-materialization',
|
||||
'fixture-descriptor', 'fixture-session'}
|
||||
executor = Executor(Journal(Path(self.temp.name) / 'private'),
|
||||
{adapter.reference: adapter}, fixture_verifier, browser_inputs=inputs.BrowserInputs(root))
|
||||
admitted = executor.admit(case_source, type_source, self.desc_bytes, catalog,
|
||||
principal='founder', actor='human', lane=invocation.lane,
|
||||
acceptance_evidence='fixture-acceptance', materialization_evidence='fixture-materialization',
|
||||
descriptor_evidence='fixture-descriptor', session_evidence='fixture-session')
|
||||
self.assertEqual(admitted[1], invocation)
|
||||
return executor, admitted
|
||||
|
||||
def test_actual_kernel_admission_dispatch_and_rollback_with_fixture_only_trust(self):
|
||||
executor, admitted = self.kernel_fixture()
|
||||
result = executor.run(*admitted)
|
||||
self.assertEqual(result.last.outcome, Outcome.SUCCESS)
|
||||
self.assertEqual(result.last.created, ('keycape-client',))
|
||||
undone = executor.rollback(*admitted)
|
||||
self.assertEqual(undone.last.outcome, Outcome.ROLLED_BACK)
|
||||
self.assertEqual(len(self.effects), 2)
|
||||
self.assertEqual(len(self.revocations), 2)
|
||||
|
||||
def test_kernel_lost_readback_retains_uncertain_and_refuses_replay_and_undo(self):
|
||||
executor, admitted = self.kernel_fixture()
|
||||
original_kube = self.kube
|
||||
def lost(args, payload=None):
|
||||
if self.effects and args[:5] == ['-n', 'sso', 'get', 'secret', 'keycape-config']:
|
||||
raise RuntimeError('synthetic sensitive detail')
|
||||
return original_kube(args, payload)
|
||||
with patch.object(a.helper.lane, 'kubectl', side_effect=lost):
|
||||
result = executor.run(*admitted)
|
||||
self.assertEqual(result.last.outcome, Outcome.UNCERTAIN)
|
||||
self.assertFalse(result.last.created)
|
||||
with self.assertRaises(Refused):
|
||||
executor.run(*admitted, retry=True)
|
||||
with self.assertRaises(Refused):
|
||||
executor.rollback(*admitted)
|
||||
self.assertEqual(len(self.effects), 1)
|
||||
self.assertEqual(len(self.revocations), 1)
|
||||
|
||||
def test_kernel_external_revision_change_retains_uncertain_rollback(self):
|
||||
executor, admitted = self.kernel_fixture()
|
||||
self.assertEqual(executor.run(*admitted).last.outcome, Outcome.SUCCESS)
|
||||
self.secret['metadata']['resourceVersion'] = '9'
|
||||
self.assertEqual(executor.rollback(*admitted).last.outcome, Outcome.ROLLBACK_UNCERTAIN)
|
||||
self.assertEqual(len(self.effects), 1)
|
||||
self.assertEqual(len(self.revocations), 2)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Focused regression tests for the code-defined OpenBao KeyCape client."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
MODULE_PATH = Path(__file__).with_name("openbao-client-config.py")
|
||||
SPEC = importlib.util.spec_from_file_location("openbao_client_config", MODULE_PATH)
|
||||
assert SPEC and SPEC.loader
|
||||
MODULE = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(MODULE)
|
||||
|
||||
LOOPBACK_CALLBACK = "http://127.0.0.1:18200/ui/vault/auth/netkingdom/oidc/callback"
|
||||
|
||||
|
||||
class OpenBaoClientConfigTest(unittest.TestCase):
|
||||
def test_operator_tunnel_callback_is_required(self) -> None:
|
||||
self.assertIn(LOOPBACK_CALLBACK, MODULE.OPENBAO_CLIENT["redirectUris"])
|
||||
|
||||
def test_missing_operator_tunnel_callback_fails_verification(self) -> None:
|
||||
client = dict(MODULE.OPENBAO_CLIENT)
|
||||
client["redirectUris"] = [
|
||||
uri for uri in MODULE.OPENBAO_CLIENT["redirectUris"] if uri != LOOPBACK_CALLBACK
|
||||
]
|
||||
errors = MODULE.client_errors({"clients": [client]}, MODULE.OPENBAO_CLIENT)
|
||||
self.assertEqual(
|
||||
errors,
|
||||
[f"openbao-admin.redirectUris missing: {LOOPBACK_CALLBACK}"],
|
||||
)
|
||||
|
||||
def test_retired_public_callbacks_are_not_declared(self) -> None:
|
||||
for uri in MODULE.RETIRED_OPENBAO_REDIRECT_URIS:
|
||||
self.assertNotIn(uri, MODULE.OPENBAO_CLIENT["redirectUris"])
|
||||
|
||||
def test_retired_public_callback_fails_verification(self) -> None:
|
||||
client = dict(MODULE.OPENBAO_CLIENT)
|
||||
retired = MODULE.RETIRED_OPENBAO_REDIRECT_URIS[0]
|
||||
client["redirectUris"] = [*MODULE.OPENBAO_CLIENT["redirectUris"], retired]
|
||||
self.assertEqual(
|
||||
MODULE.retired_redirect_errors({"clients": [client]}),
|
||||
[f"openbao-admin.redirectUris retired: {retired}"],
|
||||
)
|
||||
|
||||
def test_upsert_removes_retired_callbacks(self) -> None:
|
||||
stale = dict(MODULE.OPENBAO_CLIENT)
|
||||
stale["redirectUris"] = [*MODULE.OPENBAO_CLIENT["redirectUris"], *MODULE.RETIRED_OPENBAO_REDIRECT_URIS]
|
||||
updated = MODULE.upsert_client({"clients": [stale]}, MODULE.OPENBAO_CLIENT)
|
||||
self.assertEqual(MODULE.retired_redirect_errors(updated), [])
|
||||
|
||||
def test_upsert_preserves_unrelated_clients(self) -> None:
|
||||
config = {"clients": [{"clientId": "unrelated", "clientType": "public"}]}
|
||||
updated = MODULE.upsert_client(config, MODULE.OPENBAO_CLIENT)
|
||||
self.assertEqual(updated["clients"][0]["clientId"], "unrelated")
|
||||
self.assertIn(LOOPBACK_CALLBACK, updated["clients"][1]["redirectUris"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -0,0 +1,605 @@
|
|||
import base64
|
||||
import copy
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import time
|
||||
import io
|
||||
import os
|
||||
import urllib.error
|
||||
import signal
|
||||
import threading
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
spec = importlib.util.spec_from_file_location('session', Path(__file__).with_name('openbao-negative-session.py'))
|
||||
s = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(s)
|
||||
|
||||
|
||||
def baseline():
|
||||
reference = dict(s.registrations()[-1], clientId='secrets-engine-openbao',
|
||||
secretRef='env:PRODUCTION', tokenLifetime='15m')
|
||||
return '# preserve this comment\nclients:\n' + s.yaml.safe_dump([reference], sort_keys=False) + 'other: unchanged\n'
|
||||
|
||||
|
||||
def envelope():
|
||||
return {'proposal_id': s.PROPOSAL, 'target': s.TARGET, 'disposition': 'approved',
|
||||
'founder_decision_id': 'fixture-decision',
|
||||
'source_hashes': {str(Path(s.__file__).resolve()): s.digest(Path(s.__file__).read_bytes())},
|
||||
'owner_acknowledgments': {o: 'fixture-owner-review' for o in
|
||||
['net-kingdom', 'key-cape', 'railiance-platform']},
|
||||
'valid_until_epoch': time.time() + 900, 'maximum_session_seconds': 900,
|
||||
'bao_addr': 'http://openbao.openbao.svc:8200',
|
||||
'bao_health_identity_sha256': s.digest(b'fixture-health-cluster'),
|
||||
'role_observation': {'mount': 'keycape-services', 'role': 'secrets-engine-openbao',
|
||||
'issuer': 'https://kc.coulomb.social', 'algorithm': 'RS256',
|
||||
'bindings_exact': True, 'policy_exact': True, 'raw_expiration_leeway': 0,
|
||||
'effective_expiration_leeway': 150, 'observed_at_epoch': time.time()}}
|
||||
|
||||
|
||||
class Tests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
s.REQUEST_DEADLINE = None
|
||||
s.BUSINESS_MONOTONIC_DEADLINE = None
|
||||
s.CLEANUP_MONOTONIC_DEADLINE = None
|
||||
s.CLEANING = False
|
||||
def test_preserve_original_bytes(self):
|
||||
raw = baseline()
|
||||
result = s.candidate_config(raw)
|
||||
start = result.index('clientId: openbao-probe-aud')
|
||||
self.assertIn('# preserve this comment', result[:start])
|
||||
parsed = s.yaml.safe_load(result)
|
||||
self.assertEqual(parsed['clients'][:-6], s.yaml.safe_load(raw)['clients'])
|
||||
self.assertEqual(parsed['other'], 'unchanged')
|
||||
|
||||
def test_isolation_and_sixth_matching_expiry(self):
|
||||
clients = s.registrations()
|
||||
self.assertNotIn('audience', clients[0])
|
||||
for c in clients[1:]:
|
||||
self.assertEqual(c['audience'], 'secrets-engine-openbao')
|
||||
for key in ['roles', 'tenant', 'serviceSubject', 'allowedScopes']:
|
||||
self.assertEqual(clients[-1][key], s.yaml.safe_load(baseline())['clients'][0][key])
|
||||
|
||||
def test_preexisting_probe_refused(self):
|
||||
raw = baseline().replace('other: unchanged', '- clientId: openbao-probe-aud\nother: unchanged')
|
||||
with self.assertRaises(s.Refused):
|
||||
s.candidate_config(raw)
|
||||
|
||||
def test_duplicate_yaml_refused(self):
|
||||
with self.assertRaises(s.Refused):
|
||||
s.candidate_config(baseline() + 'other: shadow\n')
|
||||
|
||||
def test_production_drift_refused(self):
|
||||
with self.assertRaises(s.Refused):
|
||||
s.candidate_config(baseline().replace('tenant:platform', 'tenant:other'))
|
||||
|
||||
def test_env_collision_refused(self):
|
||||
spec = {'replicas': 1, 'strategy': {'type': 'Recreate'}, 'template': {'spec': {'containers': [
|
||||
{'name': 'keycape', 'env': [{'name': s.ENVS[0], 'value': 'fixture'}]}]}}}
|
||||
with self.assertRaises(s.Refused):
|
||||
s.candidate_spec(spec)
|
||||
|
||||
def test_recovery_roundtrip_preserves_every_baseline_byte(self):
|
||||
raw = baseline()
|
||||
self.assertEqual(s.recovered_config(s.candidate_config(raw)), raw)
|
||||
self.assertEqual(s.recovered_config(raw), raw)
|
||||
|
||||
def test_recovery_claim_drift_and_partial_registration_refused(self):
|
||||
raw = s.candidate_config(baseline())
|
||||
with self.assertRaises(s.Refused):
|
||||
s.recovered_config(raw.replace('service:openbao-probe', 'service:changed'))
|
||||
with self.assertRaises(s.Refused):
|
||||
s.recovered_config(raw.replace('clientId: openbao-probe-aud', 'clientId: different-probe'))
|
||||
|
||||
def test_recovery_env_roundtrip(self):
|
||||
spec = {'replicas': 1, 'strategy': {'type': 'Recreate'}, 'template': {'spec': {'containers': [
|
||||
{'name': 'keycape', 'env': [{'name': 'ORIGINAL', 'value': 'fixture'}]}]}}}
|
||||
self.assertEqual(s.recovered_spec(s.candidate_spec(spec)), spec)
|
||||
|
||||
def test_approval_is_not_optional(self):
|
||||
e = envelope()
|
||||
del e['founder_decision_id']
|
||||
with self.assertRaises(s.Refused):
|
||||
s.verify_sources(e)
|
||||
|
||||
def test_source_change_refused(self):
|
||||
e = envelope()
|
||||
e['source_hashes'][str(Path(s.__file__).resolve())] = '0' * 64
|
||||
with self.assertRaises(s.Refused):
|
||||
s.verify_sources(e)
|
||||
|
||||
def test_owner_missing_refused(self):
|
||||
e = envelope()
|
||||
del e['owner_acknowledgments']['net-kingdom']
|
||||
with self.assertRaises(s.Refused):
|
||||
s.verify_sources(e)
|
||||
|
||||
def test_leeway_default_and_disabled(self):
|
||||
e = envelope()
|
||||
self.assertEqual(s.verify_sources(e), 150)
|
||||
e['role_observation'].update(raw_expiration_leeway=-1, effective_expiration_leeway=0)
|
||||
self.assertEqual(s.verify_sources(e), 0)
|
||||
|
||||
def test_stale_role_refused(self):
|
||||
e = envelope()
|
||||
e['role_observation']['observed_at_epoch'] -= 301
|
||||
with self.assertRaises(s.Refused):
|
||||
s.verify_sources(e)
|
||||
|
||||
def test_wrong_effective_leeway_refused(self):
|
||||
e = envelope()
|
||||
e['role_observation']['effective_expiration_leeway'] = 0
|
||||
with self.assertRaises(s.Refused):
|
||||
s.verify_sources(e)
|
||||
|
||||
def test_bao_credentials_in_origin_refused(self):
|
||||
e = envelope()
|
||||
e['bao_addr'] = 'https://user:password@example.invalid'
|
||||
with self.assertRaises(s.Refused):
|
||||
s.verify_sources(e)
|
||||
|
||||
def test_future_role_observation_refused(self):
|
||||
e = envelope()
|
||||
e['role_observation']['observed_at_epoch'] = time.time() + 60
|
||||
with self.assertRaises(s.Refused):
|
||||
s.verify_sources(e)
|
||||
|
||||
def test_arbitrary_https_origin_refused(self):
|
||||
e = envelope()
|
||||
e['bao_addr'] = 'https://attacker.invalid'
|
||||
with self.assertRaises(s.Refused):
|
||||
s.verify_sources(e)
|
||||
|
||||
def test_hostile_proxy_is_disabled(self):
|
||||
build = s.urllib.request.build_opener
|
||||
with patch.dict(os.environ, {'HTTPS_PROXY':'http://hostile-proxy.invalid:8000',
|
||||
'HTTP_PROXY':'http://hostile-proxy.invalid:8000'}), \
|
||||
patch.object(s.urllib.request, 'build_opener', wraps=build) as observed:
|
||||
transport = s.SessionTransport('http://openbao.openbao.svc:8200')
|
||||
handlers = observed.call_args.args
|
||||
self.assertIsInstance(handlers[0], s.urllib.request.ProxyHandler)
|
||||
self.assertEqual(handlers[0].proxies, {})
|
||||
self.assertIsInstance(handlers[1], s.NoRedirect)
|
||||
self.assertIsNone(handlers[1].redirect_request(None, None, 302, '', {}, 'https://hostile.invalid'))
|
||||
|
||||
def test_redirect_never_follows_or_counts_as_refusal(self):
|
||||
transport = s.SessionTransport('http://openbao.openbao.svc:8200')
|
||||
response = urllib.error.HTTPError('https://kc.coulomb.social/token', 302,
|
||||
'redirect', {}, io.BytesIO(b'{}'))
|
||||
with patch.object(transport.opener, 'open', side_effect=response) as observed:
|
||||
status, raw = transport.post('https://kc.coulomb.social/token', form={})
|
||||
self.assertEqual(status, 302)
|
||||
self.assertEqual(observed.call_count, 1)
|
||||
|
||||
def test_oversized_http_body_is_closed_and_refused(self):
|
||||
transport = s.SessionTransport('http://openbao.openbao.svc:8200')
|
||||
response = type('Response', (), {'getcode':lambda self: 200,
|
||||
'read':lambda self, size: b'x' * size, 'close':lambda self: setattr(self, 'closed', True)})()
|
||||
with patch.object(transport.opener, 'open', return_value=response):
|
||||
with self.assertRaisesRegex(s.Refused, 'http_response_oversized'):
|
||||
transport.post('https://kc.coulomb.social/token', form={})
|
||||
self.assertTrue(response.closed)
|
||||
|
||||
def test_expired_window_prevents_protected_kubernetes_and_http(self):
|
||||
s.REQUEST_DEADLINE = time.time() - 1
|
||||
with patch.object(s.subprocess, 'run') as backend:
|
||||
with self.assertRaisesRegex(s.Refused, 'approved_window_expired'):
|
||||
s.k(['create', '-f', '/dev/stdin'], {})
|
||||
backend.assert_not_called()
|
||||
transport = s.SessionTransport('http://openbao.openbao.svc:8200')
|
||||
with patch.object(transport.opener, 'open') as backend:
|
||||
with self.assertRaisesRegex(s.Refused, 'approved_window_expired'):
|
||||
transport.post('https://kc.coulomb.social/token', form={})
|
||||
backend.assert_not_called()
|
||||
|
||||
def test_cleanup_revoke_allowed_after_window_expiry(self):
|
||||
s.REQUEST_DEADLINE = time.time() - 1
|
||||
transport = s.SessionTransport('http://openbao.openbao.svc:8200')
|
||||
response = type('Response', (), {'getcode':lambda self: 204,
|
||||
'read':lambda self, size: b'', 'close':lambda self: None})()
|
||||
with patch.object(transport.opener, 'open', return_value=response):
|
||||
self.assertEqual(transport.post('http://openbao.openbao.svc:8200/v1/auth/token/revoke-self'), (204, b''))
|
||||
|
||||
def test_http_read_has_finite_size(self):
|
||||
transport = s.SessionTransport('http://openbao.openbao.svc:8200')
|
||||
sizes = []
|
||||
response = type('Response', (), {'getcode':lambda self: 200,
|
||||
'read':lambda self, size: sizes.append(size) or b'{}', 'close':lambda self: None})()
|
||||
with patch.object(transport.opener, 'open', return_value=response):
|
||||
transport.post('https://kc.coulomb.social/token')
|
||||
self.assertEqual(sizes, [s.MAX_RESPONSE + 1])
|
||||
|
||||
def test_nonfinite_and_boolean_timestamps_refused(self):
|
||||
for value in [float('inf'), float('-inf'), float('nan'), True, False, 'Infinity', 10 ** 1000]:
|
||||
for field in ['valid_until_epoch', 'observed_at_epoch']:
|
||||
with self.subTest(field=field, value_type=type(value).__name__):
|
||||
e = envelope()
|
||||
if field == 'valid_until_epoch':
|
||||
e[field] = value
|
||||
else:
|
||||
e['role_observation'][field] = value
|
||||
with self.assertRaises(s.Refused):
|
||||
s.verify_sources(e)
|
||||
|
||||
def test_finite_far_future_and_noninteger_window_refused(self):
|
||||
e = envelope()
|
||||
e['valid_until_epoch'] = time.time() + 86400
|
||||
with self.assertRaises(s.Refused):
|
||||
s.verify_sources(e)
|
||||
for value in [True, 900.0, float('inf'), float('nan'), '900']:
|
||||
e = envelope()
|
||||
e['maximum_session_seconds'] = value
|
||||
with self.assertRaises(s.Refused):
|
||||
s.verify_sources(e)
|
||||
|
||||
def test_recovery_also_requires_finite_typed_window(self):
|
||||
e = envelope()
|
||||
e['disposition'] = 'recovery-approved'
|
||||
e['valid_until_epoch'] = float('inf')
|
||||
with self.assertRaises(s.Refused):
|
||||
s.verify_sources(e, recovery=True)
|
||||
|
||||
def test_nonfinite_json_constants_refused(self):
|
||||
for constant in ['Infinity', '-Infinity', 'NaN']:
|
||||
with self.assertRaises(s.Refused):
|
||||
s.read_json('{"valid_until_epoch":' + constant + '}')
|
||||
self.assertFalse(s.finite_number(s.read_json('{"valid_until_epoch":1e999}')['valid_until_epoch']))
|
||||
|
||||
def test_hard_timer_covers_slow_trickle_body_and_closes_response(self):
|
||||
transport = s.SessionTransport('http://openbao.openbao.svc:8200')
|
||||
class Trickle:
|
||||
def getcode(self):
|
||||
return 200
|
||||
def read(self, size):
|
||||
for _ in range(100):
|
||||
time.sleep(0.01)
|
||||
return b'{}'
|
||||
def close(self):
|
||||
self.closed = True
|
||||
response = Trickle()
|
||||
previous = signal.getsignal(signal.SIGALRM)
|
||||
started = time.monotonic()
|
||||
with patch.object(transport.opener, 'open', return_value=response):
|
||||
with self.assertRaisesRegex(s.Refused, 'request_total_timeout'):
|
||||
transport.post('https://kc.coulomb.social/token', timeout=0.04)
|
||||
self.assertLess(time.monotonic() - started, 0.25)
|
||||
self.assertTrue(response.closed)
|
||||
self.assertIs(signal.getsignal(signal.SIGALRM), previous)
|
||||
self.assertEqual(signal.getitimer(signal.ITIMER_REAL), (0.0, 0.0))
|
||||
|
||||
def test_hard_timer_spans_headers_and_clips_to_business_window(self):
|
||||
transport = s.SessionTransport('http://openbao.openbao.svc:8200')
|
||||
s.REQUEST_DEADLINE = time.time() + 60
|
||||
s.BUSINESS_MONOTONIC_DEADLINE = time.monotonic() + 0.04
|
||||
def slow_headers(*args, **kwargs):
|
||||
time.sleep(1)
|
||||
raise AssertionError('inactivity timeout would have permitted this')
|
||||
started = time.monotonic()
|
||||
with patch.object(transport.opener, 'open', side_effect=slow_headers):
|
||||
with self.assertRaisesRegex(s.Refused, 'request_total_timeout'):
|
||||
transport.post('https://kc.coulomb.social/token')
|
||||
self.assertLess(time.monotonic() - started, 0.25)
|
||||
|
||||
def test_late_full_acceptance_is_revoked_then_refused(self):
|
||||
receipt = {}
|
||||
transport = s.SessionTransport('http://openbao.openbao.svc:8200', receipt=receipt)
|
||||
token = 'not-a-real-late-token'
|
||||
class Late:
|
||||
def getcode(self):
|
||||
return 200
|
||||
def read(self, size):
|
||||
s.REQUEST_DEADLINE = time.time() - 1
|
||||
return json.dumps({'auth': {'client_token': token}}).encode()
|
||||
def close(self):
|
||||
pass
|
||||
revoke = type('Response', (), {'getcode':lambda self: 204,
|
||||
'read':lambda self, size: b'', 'close':lambda self: None})()
|
||||
with patch.object(transport.opener, 'open', side_effect=[Late(), revoke]) as calls:
|
||||
with self.assertRaisesRegex(s.Refused, 'approved_window_expired'):
|
||||
transport.post('http://openbao.openbao.svc:8200/v1/auth/keycape-services/login')
|
||||
self.assertEqual(calls.call_count, 2)
|
||||
self.assertEqual(calls.call_args_list[1].args[0].full_url,
|
||||
'http://openbao.openbao.svc:8200/v1/auth/token/revoke-self')
|
||||
self.assertTrue(receipt['late_acceptance_revoke_attempted'])
|
||||
self.assertTrue(receipt['late_acceptance_revoke_succeeded'])
|
||||
self.assertNotIn(token, json.dumps(receipt))
|
||||
|
||||
def test_late_acceptance_failed_revoke_remains_unconfirmed(self):
|
||||
receipt = {}
|
||||
transport = s.SessionTransport('http://openbao.openbao.svc:8200', receipt=receipt)
|
||||
class Late:
|
||||
def getcode(self):
|
||||
return 200
|
||||
def read(self, size):
|
||||
s.REQUEST_DEADLINE = time.time() - 1
|
||||
return b'{"auth":{"client_token":"fake-token"}}'
|
||||
def close(self):
|
||||
pass
|
||||
failed = type('Response', (), {'getcode':lambda self: 403,
|
||||
'read':lambda self, size: b'{}', 'close':lambda self: None})()
|
||||
with patch.object(transport.opener, 'open', side_effect=[Late(), failed]):
|
||||
with self.assertRaises(s.Refused):
|
||||
transport.post('http://openbao.openbao.svc:8200/v1/auth/keycape-services/login')
|
||||
self.assertTrue(receipt['late_acceptance_revoke_attempted'])
|
||||
self.assertFalse(receipt['late_acceptance_revoke_succeeded'])
|
||||
|
||||
def test_partial_accepted_response_timeout_retains_unknown_outcome(self):
|
||||
receipt = {}
|
||||
transport = s.SessionTransport('http://openbao.openbao.svc:8200', receipt=receipt)
|
||||
response = type('Response', (), {'getcode':lambda self: 200,
|
||||
'read':lambda self, size: time.sleep(1), 'close':lambda self: None})()
|
||||
with patch.object(transport.opener, 'open', return_value=response):
|
||||
with self.assertRaisesRegex(s.Refused, 'request_total_timeout'):
|
||||
transport.post('http://openbao.openbao.svc:8200/v1/auth/keycape-services/login', timeout=0.04)
|
||||
self.assertTrue(receipt['openbao_login_outcome_unknown'])
|
||||
|
||||
def test_cleanup_has_finite_aggregate_deadline_and_restoration(self):
|
||||
s.REQUEST_DEADLINE = time.time() - 1
|
||||
s.begin_cleanup()
|
||||
first = s.CLEANUP_MONOTONIC_DEADLINE
|
||||
s.begin_cleanup()
|
||||
self.assertEqual(s.CLEANUP_MONOTONIC_DEADLINE, first)
|
||||
s.CLEANUP_MONOTONIC_DEADLINE = time.monotonic() - 1
|
||||
s.CLEANING = True
|
||||
with self.assertRaisesRegex(s.Refused, 'cleanup_budget_expired'):
|
||||
s.check_deadline()
|
||||
transport = s.SessionTransport('http://openbao.openbao.svc:8200')
|
||||
with patch.object(transport.opener, 'open') as backend:
|
||||
with self.assertRaises(s.Refused):
|
||||
transport.post('http://openbao.openbao.svc:8200/v1/auth/token/revoke-self')
|
||||
backend.assert_not_called()
|
||||
|
||||
def test_expired_cleanup_cannot_issue_native_secret_delete(self):
|
||||
s.CLEANING = True
|
||||
s.CLEANUP_MONOTONIC_DEADLINE = time.monotonic() - 1
|
||||
obj = {'metadata': {'uid':'temporary', 'resourceVersion':'7'}}
|
||||
with patch.object(s.subprocess, 'run') as native:
|
||||
with self.assertRaisesRegex(s.Refused, 'cleanup_budget_expired'):
|
||||
s.delete_temporary(obj)
|
||||
native.assert_not_called()
|
||||
|
||||
def test_secret_delete_total_timeout_clipped_to_cleanup_budget(self):
|
||||
s.CLEANING = True
|
||||
s.CLEANUP_MONOTONIC_DEADLINE = time.monotonic() + 0.04
|
||||
obj = {'metadata': {'uid':'temporary', 'resourceVersion':'7'}}
|
||||
captured = {}
|
||||
def slow_delete(args, **kwargs):
|
||||
captured.update(kwargs)
|
||||
time.sleep(1)
|
||||
return type('Result', (), {'returncode':0, 'stdout':'{}'})()
|
||||
started = time.monotonic()
|
||||
with patch.object(s.subprocess, 'run', side_effect=slow_delete):
|
||||
with self.assertRaisesRegex(s.Refused, 'request_total_timeout'):
|
||||
s.delete_temporary(obj)
|
||||
self.assertLess(time.monotonic() - started, 0.25)
|
||||
self.assertLessEqual(captured['timeout'], 0.04)
|
||||
self.assertEqual(json.loads(captured['input'])['preconditions'], {'uid':'temporary', 'resourceVersion':'7'})
|
||||
|
||||
def test_early_read_failure_slow_close_is_bounded_once(self):
|
||||
receipt = {}
|
||||
transport = s.SessionTransport('http://openbao.openbao.svc:8200', receipt=receipt)
|
||||
class Response:
|
||||
closes = 0
|
||||
def getcode(self):
|
||||
return 200
|
||||
def read(self, size):
|
||||
raise s.Refused('synthetic_read_failure')
|
||||
def close(self):
|
||||
self.closes += 1
|
||||
time.sleep(0.07)
|
||||
response = Response()
|
||||
previous = signal.getsignal(signal.SIGALRM)
|
||||
started = time.monotonic()
|
||||
with patch.object(transport.opener, 'open', return_value=response):
|
||||
with self.assertRaisesRegex(s.Refused, 'http_response_abort_unconfirmed'):
|
||||
transport.post('http://openbao.openbao.svc:8200/v1/auth/keycape-services/login', timeout=0.01)
|
||||
self.assertLess(time.monotonic() - started, 0.05)
|
||||
self.assertEqual(response.closes, 1)
|
||||
self.assertTrue(receipt['openbao_login_outcome_unknown'])
|
||||
self.assertTrue(receipt['http_abort_close_attempted'])
|
||||
self.assertFalse(receipt['http_abort_close_succeeded'])
|
||||
self.assertTrue(receipt['http_response_abort_unconfirmed'])
|
||||
self.assertIs(signal.getsignal(signal.SIGALRM), previous)
|
||||
self.assertEqual(signal.getitimer(signal.ITIMER_REAL), (0.0, 0.0))
|
||||
|
||||
def test_total_read_timeout_then_slow_close_uses_only_abort_cap(self):
|
||||
receipt = {}
|
||||
transport = s.SessionTransport('http://openbao.openbao.svc:8200', receipt=receipt)
|
||||
class Response:
|
||||
closes = 0
|
||||
def getcode(self):
|
||||
return 200
|
||||
def read(self, size):
|
||||
time.sleep(0.1)
|
||||
def close(self):
|
||||
self.closes += 1
|
||||
time.sleep(0.1)
|
||||
response = Response()
|
||||
started = time.monotonic()
|
||||
with patch.object(transport.opener, 'open', return_value=response):
|
||||
with self.assertRaisesRegex(s.Refused, 'http_response_abort_unconfirmed'):
|
||||
transport.post('http://openbao.openbao.svc:8200/v1/auth/keycape-services/login', timeout=0.01)
|
||||
self.assertLess(time.monotonic() - started, 0.06)
|
||||
self.assertEqual(response.closes, 1)
|
||||
self.assertTrue(receipt['openbao_login_outcome_unknown'])
|
||||
self.assertFalse(receipt['http_abort_close_succeeded'])
|
||||
|
||||
def test_oversized_body_slow_abort_remains_unproved_and_bounded(self):
|
||||
receipt = {}
|
||||
transport = s.SessionTransport('http://openbao.openbao.svc:8200', receipt=receipt)
|
||||
response = type('Response', (), {'getcode':lambda self: 200,
|
||||
'read':lambda self, size: b'x' * size,
|
||||
'close':lambda self: time.sleep(0.1)})()
|
||||
started = time.monotonic()
|
||||
with patch.object(transport.opener, 'open', return_value=response):
|
||||
with self.assertRaisesRegex(s.Refused, 'http_response_abort_unconfirmed'):
|
||||
transport.post('http://openbao.openbao.svc:8200/v1/auth/keycape-services/login', timeout=0.01)
|
||||
self.assertLess(time.monotonic() - started, 0.05)
|
||||
self.assertTrue(receipt['openbao_login_outcome_unknown'])
|
||||
self.assertFalse(receipt['http_abort_close_succeeded'])
|
||||
|
||||
def test_raising_normal_close_then_slow_abort_is_private_bounded_unknown(self):
|
||||
receipt = {}
|
||||
transport = s.SessionTransport('http://openbao.openbao.svc:8200', receipt=receipt)
|
||||
class Response:
|
||||
closes = 0
|
||||
def getcode(self):
|
||||
return 200
|
||||
def read(self, size):
|
||||
return b'{}'
|
||||
def close(self):
|
||||
self.closes += 1
|
||||
if self.closes == 1:
|
||||
raise RuntimeError('private protected error must not escape')
|
||||
time.sleep(0.1)
|
||||
response = Response()
|
||||
previous = signal.getsignal(signal.SIGALRM)
|
||||
started = time.monotonic()
|
||||
with patch.object(transport.opener, 'open', return_value=response):
|
||||
with self.assertRaisesRegex(s.Refused, '^http_response_abort_unconfirmed$'):
|
||||
transport.post('http://openbao.openbao.svc:8200/v1/auth/keycape-services/login', timeout=0.01)
|
||||
self.assertLess(time.monotonic() - started, 0.05)
|
||||
self.assertEqual(response.closes, 2)
|
||||
self.assertTrue(receipt['openbao_login_outcome_unknown'])
|
||||
self.assertFalse(receipt['http_abort_close_succeeded'])
|
||||
self.assertIs(signal.getsignal(signal.SIGALRM), previous)
|
||||
self.assertEqual(signal.getitimer(signal.ITIMER_REAL), (0.0, 0.0))
|
||||
|
||||
def test_expired_cleanup_never_attempts_exceptional_close_or_resets(self):
|
||||
receipt = {}
|
||||
transport = s.SessionTransport('http://openbao.openbao.svc:8200', receipt=receipt)
|
||||
deadline = time.monotonic() - 1
|
||||
s.CLEANUP_MONOTONIC_DEADLINE = deadline
|
||||
response = type('Response', (), {'close':lambda self: self.fail('must not close')})()
|
||||
with patch.object(response, 'close') as closes:
|
||||
with self.assertRaisesRegex(s.Refused, 'http_response_abort_unconfirmed'):
|
||||
transport.abort_response(response, time.monotonic() - 1)
|
||||
closes.assert_not_called()
|
||||
self.assertEqual(s.CLEANUP_MONOTONIC_DEADLINE, deadline)
|
||||
self.assertFalse(receipt['http_abort_close_attempted'])
|
||||
self.assertTrue(receipt['http_response_abort_unconfirmed'])
|
||||
|
||||
def test_thread_or_existing_timer_refused_without_overwrite(self):
|
||||
results = []
|
||||
def in_thread():
|
||||
try:
|
||||
with s.total_timeout(0.1):
|
||||
results.append('unreachable')
|
||||
except s.Refused:
|
||||
results.append('refused')
|
||||
thread = threading.Thread(target=in_thread)
|
||||
thread.start()
|
||||
thread.join()
|
||||
self.assertEqual(results, ['refused'])
|
||||
signal.setitimer(signal.ITIMER_REAL, 1)
|
||||
try:
|
||||
with self.assertRaisesRegex(s.Refused, 'existing_alarm_timer_refused'):
|
||||
with s.total_timeout(0.1):
|
||||
pass
|
||||
self.assertGreater(signal.getitimer(signal.ITIMER_REAL)[0], 0.5)
|
||||
finally:
|
||||
signal.setitimer(signal.ITIMER_REAL, 0)
|
||||
|
||||
def test_cleanup_refuses_concurrent_registry_change(self):
|
||||
self.lifecycle(drift=True)
|
||||
|
||||
def test_deleted_client_contract_is_specific(self):
|
||||
self.assertTrue(s.removed_client_denied(401, b'{"error":"invalid_profile_usage","feature":"client_id"}'))
|
||||
for status, body in [(503, b'{}'), (401, b'{"error":"invalid_profile_usage","feature":"Authorization"}'),
|
||||
(401, b'{"error":"invalid_client"}'), (401, b'not-json')]:
|
||||
self.assertFalse(s.removed_client_denied(status, body))
|
||||
|
||||
def test_cleanup_even_when_probe_fails(self):
|
||||
self.lifecycle(probe_pass=False)
|
||||
|
||||
def test_complete_lifecycle(self):
|
||||
self.lifecycle()
|
||||
|
||||
def lifecycle(self, drift=False, probe_pass=True):
|
||||
raw = baseline()
|
||||
dep_spec = {'replicas': 1, 'strategy': {'type': 'Recreate'}, 'template': {'spec': {'containers': [
|
||||
{'name': 'keycape', 'image': 'fixture@sha256:abc', 'env': []}]}}}
|
||||
objects = {'keycape-config': {'metadata': {'name': 'keycape-config', 'uid': 'cfg', 'resourceVersion': '1'},
|
||||
'data': {'config.yaml': base64.b64encode(raw.encode()).decode(),
|
||||
'key.pem': 'FAKE_KEY_NOT_READ'}},
|
||||
'keycape': {'metadata': {'name': 'keycape', 'uid': 'dep', 'resourceVersion': '1'},
|
||||
'spec': dep_spec}}
|
||||
original = copy.deepcopy(objects)
|
||||
e = envelope()
|
||||
e.update(runner_path='fake-runner', config_metadata=s.meta(objects['keycape-config']),
|
||||
deployment_metadata=s.meta(objects['keycape']), deployment_spec_sha256=s.canonical(dep_spec),
|
||||
registry_sha256=s.digest(raw.encode()))
|
||||
e['source_hashes']['fake-runner'] = 'fixture'
|
||||
receipt = {'cleanup_verified': False}
|
||||
class Probe:
|
||||
def exchange(self, *args):
|
||||
return 'fixture-token'
|
||||
def http_post(self, *args, **kwargs):
|
||||
return 401, b'{"error":"invalid_profile_usage","feature":"client_id"}'
|
||||
def run(self, *args, **kwargs):
|
||||
if drift:
|
||||
objects['keycape-config']['data']['unrelated'] = 'concurrent-owner-change'
|
||||
return {'all_refused': probe_pass}
|
||||
def kube(args, payload=None):
|
||||
if args[0] == 'get':
|
||||
return {'metadata': {'uid': s.CLUSTER}}
|
||||
if args[0] == 'create':
|
||||
if '--dry-run=server' in args:
|
||||
return copy.deepcopy(payload)
|
||||
created = copy.deepcopy(payload)
|
||||
created['metadata'].update(uid='temp', resourceVersion='1')
|
||||
objects[s.TEMP] = created
|
||||
return created
|
||||
if args[0] == 'delete':
|
||||
self.assertEqual(payload['preconditions'], {'uid':'temp', 'resourceVersion':'1'})
|
||||
del objects[s.TEMP]
|
||||
return {'kind':'Status', 'status':'Success'}
|
||||
if '--ignore-not-found' in args:
|
||||
return objects.get(s.TEMP)
|
||||
raise AssertionError('unexpected kube call')
|
||||
def patched(kind, obj, path, value, dry=False):
|
||||
result = copy.deepcopy(obj)
|
||||
if path == '/spec':
|
||||
result['spec'] = copy.deepcopy(value)
|
||||
else:
|
||||
result['data']['config.yaml'] = value
|
||||
if not dry:
|
||||
objects[obj['metadata']['name']] = result
|
||||
return result
|
||||
def delete(*args, **kwargs):
|
||||
del objects[s.TEMP]
|
||||
return type('Result', (), {'returncode': 0})()
|
||||
class Discovery:
|
||||
def __enter__(self):
|
||||
import io
|
||||
return io.StringIO('{"issuer":"https://kc.coulomb.social","token_endpoint":"https://kc.coulomb.social/token"}')
|
||||
def __exit__(self, *args):
|
||||
pass
|
||||
with patch.object(s, 'verify_sources', return_value=150), patch.object(s, 'load_probe', return_value=Probe()), \
|
||||
patch.object(s, 'SessionTransport') as transport_factory, \
|
||||
patch.object(s, 'k', side_effect=kube), patch.object(s, 'get', side_effect=lambda kind, name: copy.deepcopy(objects[name])), \
|
||||
patch.object(s, 'patch', side_effect=patched), patch.object(s, 'ready', return_value=True), \
|
||||
patch.object(s.subprocess, 'run', side_effect=delete):
|
||||
transport_factory.return_value.discovery.return_value = {
|
||||
'issuer':'https://kc.coulomb.social', 'token_endpoint':'https://kc.coulomb.social/token'}
|
||||
transport_factory.return_value.health_identity.return_value = e['bao_health_identity_sha256']
|
||||
transport_factory.return_value.post.side_effect = Probe().http_post
|
||||
if drift or not probe_pass:
|
||||
with self.assertRaises(s.Refused):
|
||||
s.execute(e, {'human_token': 'fake', 'coding_agent_token': 'fake'}, receipt)
|
||||
else:
|
||||
s.execute(e, {'human_token': 'fake', 'coding_agent_token': 'fake'}, receipt)
|
||||
if drift:
|
||||
self.assertFalse(receipt['cleanup_verified'])
|
||||
self.assertIn(s.TEMP, objects)
|
||||
self.assertEqual(objects['keycape-config']['data']['unrelated'], 'concurrent-owner-change')
|
||||
else:
|
||||
self.assertTrue(receipt['cleanup_verified'])
|
||||
self.assertNotIn(s.TEMP, objects)
|
||||
self.assertEqual(objects['keycape-config']['data'], original['keycape-config']['data'])
|
||||
self.assertEqual(objects['keycape']['spec'], original['keycape']['spec'])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
|
@ -0,0 +1,198 @@
|
|||
"""Offline route readiness: public responses and lifecycle mocks only."""
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
import copy
|
||||
import time
|
||||
import tempfile
|
||||
import sys
|
||||
import signal
|
||||
import io
|
||||
|
||||
spec=importlib.util.spec_from_file_location('fixtures',Path(__file__).with_name('test_openbao_negative_session.py'))
|
||||
f=importlib.util.module_from_spec(spec);spec.loader.exec_module(f);s=f.s
|
||||
HEALTH={'initialized':True,'sealed':False,'standby':False,'performance_standby':False,'cluster_id':'synthetic-public-cluster'}
|
||||
PIN=s.digest(HEALTH['cluster_id'].encode())
|
||||
|
||||
class NodeRouteTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
s.CLEANING=False;s.REQUEST_DEADLINE=None
|
||||
s.BUSINESS_MONOTONIC_DEADLINE=None;s.CLEANUP_MONOTONIC_DEADLINE=None
|
||||
|
||||
def test_public_health_get_has_no_authentication_and_only_digest_retained(self):
|
||||
transport=s.SessionTransport('http://127.0.0.1:18200');receipt={}
|
||||
response=type('Response',(),{'getcode':lambda self:200,'read':lambda self,size:json.dumps(HEALTH).encode(),'close':lambda self:None})()
|
||||
with patch.object(transport.opener,'open',return_value=response) as calls:
|
||||
s.verify_bao_route(transport,{'bao_health_identity_sha256':PIN},receipt)
|
||||
req=calls.call_args.args[0]
|
||||
self.assertEqual(req.full_url,'http://127.0.0.1:18200/v1/sys/health')
|
||||
self.assertEqual(req.method,'GET');self.assertEqual(req.headers,{})
|
||||
self.assertEqual(receipt,{'bao_public_route_verified':True})
|
||||
self.assertNotIn(HEALTH['cluster_id'],json.dumps(receipt))
|
||||
|
||||
def test_unreachable_redirect_sealed_standby_uninitialized_and_malformed_health_refuse(self):
|
||||
transport=s.SessionTransport('http://openbao.openbao.svc:8200')
|
||||
cases=[(None,b''),(302,b'{}'),(503,json.dumps(HEALTH).encode()),(200,b'not-json'),
|
||||
(200,b'[]'),(200,json.dumps(dict(HEALTH,sealed=True)).encode()),
|
||||
(200,json.dumps(dict(HEALTH,initialized=False)).encode()),
|
||||
(200,json.dumps(dict(HEALTH,standby=True)).encode()),
|
||||
(200,json.dumps(dict(HEALTH,sealed=0)).encode()),
|
||||
(200,json.dumps(dict(HEALTH,cluster_id='')).encode())]
|
||||
for status,body in cases:
|
||||
with self.subTest(status=status,body_length=len(body)),patch.object(transport,'request',return_value=(status,body)):
|
||||
with self.assertRaises(s.Refused):transport.health_identity()
|
||||
|
||||
def test_identity_drift_or_missing_approved_pin_refuse(self):
|
||||
transport=s.SessionTransport('http://127.0.0.1:18200')
|
||||
with patch.object(transport,'health_identity',return_value=PIN) as health:
|
||||
with self.assertRaisesRegex(s.Refused,'bao_public_health_identity_changed'):
|
||||
s.verify_bao_route(transport,{'bao_health_identity_sha256':'0'*64},{})
|
||||
health.reset_mock()
|
||||
with self.assertRaisesRegex(s.Refused,'approved_bao_health_identity_required'):
|
||||
s.verify_bao_route(transport,{}, {})
|
||||
health.assert_not_called()
|
||||
|
||||
def setup_execute(self):
|
||||
raw=f.baseline()
|
||||
spec={'replicas':1,'strategy':{'type':'Recreate'},'template':{'spec':{'containers':[{'name':'keycape','env':[]}]}}}
|
||||
config={'metadata':{'name':'keycape-config','uid':'cfg','resourceVersion':'1'},'data':{'config.yaml':s.base64.b64encode(raw.encode()).decode()}}
|
||||
dep={'metadata':{'name':'keycape','uid':'dep','resourceVersion':'1'},'spec':spec}
|
||||
e=f.envelope();e.update(runner_path='fixture-runner',bao_health_identity_sha256=PIN,
|
||||
config_metadata=s.meta(config),deployment_metadata=s.meta(dep),registry_sha256=s.digest(raw.encode()),deployment_spec_sha256=s.canonical(spec))
|
||||
e['source_hashes']['fixture-runner']='fixture'
|
||||
return e,config,dep
|
||||
|
||||
def test_unavailable_initial_node_route_stops_before_any_native_or_secret_access(self):
|
||||
e,config,dep=self.setup_execute()
|
||||
with patch.object(s,'verify_sources',return_value=150),patch.object(s,'load_probe'), \
|
||||
patch.object(s,'SessionTransport') as factory,patch.object(s,'k') as k,patch.object(s,'get') as get:
|
||||
factory.return_value.discovery.return_value={'issuer':'https://kc.coulomb.social','token_endpoint':'https://kc.coulomb.social/token'}
|
||||
factory.return_value.health_identity.side_effect=s.Refused('bao_public_health_unavailable')
|
||||
with self.assertRaisesRegex(s.Refused,'bao_public_health_unavailable'):
|
||||
s.execute(e,{'human_token':'synthetic','coding_agent_token':'synthetic'}, {})
|
||||
k.assert_not_called();get.assert_not_called()
|
||||
|
||||
def test_route_loss_after_dry_runs_stops_before_temporary_create_or_restarts(self):
|
||||
e,config,dep=self.setup_execute()
|
||||
with patch.object(s,'verify_sources',return_value=150),patch.object(s,'load_probe'), \
|
||||
patch.object(s,'SessionTransport') as factory, \
|
||||
patch.object(s,'k',return_value={'metadata':{'uid':s.CLUSTER}}) as k, \
|
||||
patch.object(s,'get',side_effect=[config,dep]),patch.object(s,'patch') as patches,patch.object(s,'ready',return_value=True):
|
||||
factory.return_value.discovery.return_value={'issuer':'https://kc.coulomb.social','token_endpoint':'https://kc.coulomb.social/token'}
|
||||
factory.return_value.health_identity.side_effect=[PIN,s.Refused('bao_public_health_unavailable')]
|
||||
with self.assertRaisesRegex(s.Refused,'bao_public_health_unavailable'):
|
||||
s.execute(e,{'human_token':'synthetic','coding_agent_token':'synthetic'}, {})
|
||||
self.assertEqual(factory.return_value.health_identity.call_count,2)
|
||||
self.assertTrue(all(c.kwargs.get('dry') is True for c in patches.call_args_list))
|
||||
self.assertTrue(all(c.args[0][0]!='create' or '--dry-run=server' in c.args[0] for c in k.call_args_list))
|
||||
|
||||
def test_recovery_route_refusal_precedes_native_reads(self):
|
||||
e,config,dep=self.setup_execute();e['disposition']='recovery-approved'
|
||||
with patch.object(s,'verify_sources',return_value=None),patch.object(s,'SessionTransport') as factory, \
|
||||
patch.object(s,'k') as k,patch.object(s,'get') as get:
|
||||
factory.return_value.health_identity.side_effect=s.Refused('bao_public_health_unavailable')
|
||||
with self.assertRaisesRegex(s.Refused,'bao_public_health_unavailable'):
|
||||
s.recover(e,{})
|
||||
k.assert_not_called();get.assert_not_called()
|
||||
|
||||
def test_public_health_uses_total_elapsed_limit(self):
|
||||
transport=s.SessionTransport('http://127.0.0.1:18200')
|
||||
class Slow:
|
||||
def getcode(self):return 200
|
||||
def read(self,size):time.sleep(0.5);return json.dumps(HEALTH).encode()
|
||||
def close(self):pass
|
||||
started=time.monotonic()
|
||||
with patch.object(s,'HTTP_TOTAL_SECONDS',0.008),patch.object(transport.opener,'open',return_value=Slow()):
|
||||
with self.assertRaisesRegex(s.Refused,'request_total_timeout'):transport.health_identity()
|
||||
self.assertLess(time.monotonic()-started,0.08)
|
||||
|
||||
def test_public_route_mode_has_no_native_secret_or_private_input(self):
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
receipt=Path(folder)/'receipt.json'
|
||||
argv=['session','route-preflight','--bao-addr','http://127.0.0.1:18200','--receipt',str(receipt)]
|
||||
with patch.object(sys,'argv',argv),patch.object(s,'SessionTransport') as factory, \
|
||||
patch.object(s,'k') as k,patch.object(s,'get') as get,patch.object(sys,'stdin') as stdin:
|
||||
factory.return_value.health_identity.return_value=PIN
|
||||
self.assertEqual(s.main(),0)
|
||||
k.assert_not_called();get.assert_not_called();stdin.read.assert_not_called()
|
||||
data=json.loads(receipt.read_text())
|
||||
self.assertEqual(data['bao_health_identity_sha256'],PIN)
|
||||
self.assertEqual(data['probe_location'],'current_process_only')
|
||||
self.assertFalse(data['native_accepted'])
|
||||
|
||||
def main_fixture(self,folder,mode):
|
||||
envelope=f.envelope()
|
||||
envelope['bao_health_identity_sha256']=PIN
|
||||
if mode=='recover':envelope['disposition']='recovery-approved'
|
||||
path=Path(folder)/'approved.json';path.write_text(json.dumps(envelope));path.chmod(0o600)
|
||||
receipt=Path(folder)/'receipt.json'
|
||||
argv=['session',mode,'--envelope',str(path),'--receipt',str(receipt)]
|
||||
return envelope,receipt,argv
|
||||
|
||||
def test_actual_main_health_failure_never_reads_private_stdin_execute_or_recover(self):
|
||||
previous=signal.getsignal(signal.SIGTERM)
|
||||
try:
|
||||
for mode in ['execute','recover']:
|
||||
with self.subTest(mode=mode),tempfile.TemporaryDirectory() as folder:
|
||||
e,receipt,argv=self.main_fixture(folder,mode)
|
||||
with patch.object(sys,'argv',argv),patch.object(s,'SessionTransport') as factory, \
|
||||
patch.object(sys,'stdin') as stdin,patch.object(s,'k') as k,patch.object(s,'get') as get:
|
||||
factory.return_value.health_identity.side_effect=s.Refused('bao_public_health_unavailable')
|
||||
self.assertEqual(s.main(),1)
|
||||
stdin.read.assert_not_called();stdin.buffer.read.assert_not_called();k.assert_not_called();get.assert_not_called()
|
||||
data=json.loads(receipt.read_text())
|
||||
self.assertEqual(data['reason'],'bao_public_health_unavailable')
|
||||
self.assertFalse(data['cleanup_verified'])
|
||||
s.CLEANING=False;s.CLEANUP_MONOTONIC_DEADLINE=None
|
||||
finally:signal.signal(signal.SIGTERM,previous)
|
||||
|
||||
def test_actual_main_missing_approved_health_pin_never_reads_stdin(self):
|
||||
previous=signal.getsignal(signal.SIGTERM)
|
||||
try:
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
e,receipt,argv=self.main_fixture(folder,'execute')
|
||||
del e['bao_health_identity_sha256']
|
||||
Path(argv[3]).write_text(json.dumps(e))
|
||||
with patch.object(sys,'argv',argv),patch.object(sys,'stdin') as stdin, \
|
||||
patch.object(s,'SessionTransport') as factory,patch.object(s,'k') as k:
|
||||
self.assertEqual(s.main(),1)
|
||||
stdin.read.assert_not_called();stdin.buffer.read.assert_not_called();factory.return_value.health_identity.assert_not_called();k.assert_not_called()
|
||||
self.assertEqual(json.loads(receipt.read_text())['reason'],'approved_bao_health_identity_required')
|
||||
finally:signal.signal(signal.SIGTERM,previous)
|
||||
|
||||
def test_actual_main_health_succeeds_before_stdin_and_preserves_started_window(self):
|
||||
previous=signal.getsignal(signal.SIGTERM);events=[];started={}
|
||||
try:
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
e,receipt,argv=self.main_fixture(folder,'execute')
|
||||
def health():
|
||||
events.append('health');started['deadline']=s.BUSINESS_MONOTONIC_DEADLINE;return PIN
|
||||
payloads=iter([b'{"human_token":"synthetic","coding_agent_token":"synthetic"}',b''])
|
||||
def private_read(size):
|
||||
if 'stdin' not in events:events.append('stdin')
|
||||
self.assertGreater(size,0);return next(payloads)
|
||||
def execute(*args,**kwargs):
|
||||
events.append('execute');self.assertTrue(kwargs['window_started'])
|
||||
self.assertEqual(s.BUSINESS_MONOTONIC_DEADLINE,started['deadline'])
|
||||
with patch.object(sys,'argv',argv),patch.object(s,'SessionTransport') as factory, \
|
||||
patch.object(sys,'stdin') as stdin,patch.object(s,'emit_private_ready'),patch.object(s,'execute',side_effect=execute), \
|
||||
patch.object(s,'k') as k:
|
||||
factory.return_value.health_identity.side_effect=health
|
||||
stdin.buffer.read.side_effect=private_read
|
||||
self.assertEqual(s.main(),0);k.assert_not_called()
|
||||
self.assertEqual(events,['health','stdin','execute'])
|
||||
finally:signal.signal(signal.SIGTERM,previous)
|
||||
|
||||
def test_execute_does_not_renew_expired_preinput_monotonic_window(self):
|
||||
e=f.envelope();s.start_execution_window(e)
|
||||
original=s.BUSINESS_MONOTONIC_DEADLINE
|
||||
with patch.object(s.time,'monotonic',return_value=original+0.01),patch.object(s,'load_probe') as load, \
|
||||
patch.object(s,'k') as k:
|
||||
with self.assertRaisesRegex(s.Refused,'session_business_budget_expired'):
|
||||
s.execute(e,{'human_token':'synthetic','coding_agent_token':'synthetic'}, {},window_started=True)
|
||||
load.assert_not_called();k.assert_not_called()
|
||||
self.assertEqual(s.BUSINESS_MONOTONIC_DEADLINE,original)
|
||||
|
||||
if __name__=='__main__':unittest.main(verbosity=2)
|
||||
|
|
@ -0,0 +1,111 @@
|
|||
"""Actual main bounded private receive; synthetic local binary pipes only."""
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
spec=importlib.util.spec_from_file_location('routes',Path(__file__).with_name('test_openbao_node_route.py'))
|
||||
r=importlib.util.module_from_spec(spec);spec.loader.exec_module(r);s=r.s;f=r.f
|
||||
PAYLOAD=b'{"human_token":"synthetic-human","coding_agent_token":"synthetic-agent"}'
|
||||
|
||||
class PrivateInputTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
s.CLEANING=False;s.REQUEST_DEADLINE=None
|
||||
s.BUSINESS_MONOTONIC_DEADLINE=None;s.CLEANUP_MONOTONIC_DEADLINE=None
|
||||
|
||||
def main_with(self,stream,*,lifetime=0.05,health=None):
|
||||
previous=signal.getsignal(signal.SIGTERM)
|
||||
try:
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
envelope=f.envelope();envelope['valid_until_epoch']=time.time()+lifetime
|
||||
envelope['bao_health_identity_sha256']=r.PIN
|
||||
path=Path(folder)/'approved.json';path.write_text(json.dumps(envelope));path.chmod(0o600)
|
||||
receipt=Path(folder)/'receipt.json'
|
||||
argv=['session','execute','--envelope',str(path),'--receipt',str(receipt)]
|
||||
with patch.object(sys,'argv',argv),patch.object(sys,'stdin',stream), \
|
||||
patch.object(s,'SessionTransport') as factory,patch.object(s,'emit_private_ready'),patch.object(s,'execute') as execute, \
|
||||
patch.object(s,'k') as k,patch.object(s,'get') as get:
|
||||
factory.return_value.health_identity.side_effect=health
|
||||
factory.return_value.health_identity.return_value=r.PIN
|
||||
rc=s.main()
|
||||
k.assert_not_called();get.assert_not_called()
|
||||
return rc,json.loads(receipt.read_text()),execute.call_args,execute.call_count
|
||||
finally:signal.signal(signal.SIGTERM,previous)
|
||||
|
||||
def test_actual_main_real_pipe_stall_has_original_window_total_bound(self):
|
||||
read_fd,write_fd=os.pipe()
|
||||
binary=os.fdopen(read_fd,'rb',buffering=0)
|
||||
stream=type('Input',(),{'buffer':binary})()
|
||||
started=time.monotonic();previous=signal.getsignal(signal.SIGALRM)
|
||||
try:
|
||||
rc,receipt,args,count=self.main_with(stream,lifetime=0.02)
|
||||
finally:os.close(write_fd);binary.close()
|
||||
self.assertEqual(rc,1);self.assertEqual(count,0)
|
||||
self.assertEqual(receipt['reason'],'request_total_timeout')
|
||||
self.assertLess(time.monotonic()-started,0.15)
|
||||
self.assertEqual(signal.getitimer(signal.ITIMER_REAL),(0.0,0.0))
|
||||
self.assertEqual(signal.getsignal(signal.SIGALRM),previous)
|
||||
|
||||
def test_actual_main_complete_json_without_eof_still_refuses(self):
|
||||
read_fd,write_fd=os.pipe();os.write(write_fd,PAYLOAD)
|
||||
binary=os.fdopen(read_fd,'rb',buffering=0);stream=type('Input',(),{'buffer':binary})()
|
||||
try:rc,receipt,args,count=self.main_with(stream,lifetime=0.02)
|
||||
finally:os.close(write_fd);binary.close()
|
||||
self.assertEqual(rc,1);self.assertEqual(count,0)
|
||||
self.assertEqual(receipt['reason'],'request_total_timeout')
|
||||
|
||||
def test_actual_main_oversized_receive_refuses_without_execution(self):
|
||||
stream=type('Input',(),{'buffer':io.BytesIO(b'x'*(s.PRIVATE_INPUT_MAX_BYTES+1))})()
|
||||
rc,receipt,args,count=self.main_with(stream)
|
||||
self.assertEqual(rc,1);self.assertEqual(count,0)
|
||||
self.assertEqual(receipt['reason'],'private_input_oversized')
|
||||
|
||||
def test_actual_main_empty_partial_invalid_or_excessive_token_refuses(self):
|
||||
inputs=[(b'','private_input_empty'),(b'{"human_token":','private_input_invalid'),
|
||||
(b'not-json','private_input_invalid'),(b'{"human_token":true,"coding_agent_token":"a"}','private_input_invalid'),
|
||||
(json.dumps({'human_token':'x'*(s.PRIVATE_TOKEN_MAX_BYTES+1),'coding_agent_token':'a'}).encode(),'private_input_invalid')]
|
||||
for raw,reason in inputs:
|
||||
with self.subTest(reason=reason,length=len(raw)):
|
||||
stream=type('Input',(),{'buffer':io.BytesIO(raw)})()
|
||||
rc,receipt,args,count=self.main_with(stream)
|
||||
self.assertEqual(rc,1);self.assertEqual(count,0);self.assertEqual(receipt['reason'],reason)
|
||||
|
||||
def test_actual_main_expiry_after_health_never_starts_private_receive(self):
|
||||
stream=type('Input',(),{'buffer':io.BytesIO(PAYLOAD)})()
|
||||
def late_health():
|
||||
s.REQUEST_DEADLINE=time.time()-1
|
||||
return r.PIN
|
||||
with patch.object(stream.buffer,'read',wraps=stream.buffer.read) as read:
|
||||
rc,receipt,args,count=self.main_with(stream,health=late_health)
|
||||
self.assertEqual(rc,1);self.assertEqual(count,0)
|
||||
self.assertEqual(receipt['reason'],'approved_window_expired');read.assert_not_called()
|
||||
|
||||
def test_actual_main_late_eof_never_decodes_or_executes(self):
|
||||
class LateInput:
|
||||
reads=0
|
||||
def read(self,size):
|
||||
self.reads+=1
|
||||
if self.reads==1:return PAYLOAD
|
||||
s.BUSINESS_MONOTONIC_DEADLINE=time.monotonic()-1
|
||||
return b''
|
||||
stream=type('Input',(),{'buffer':LateInput()})()
|
||||
rc,receipt,args,count=self.main_with(stream)
|
||||
self.assertEqual(rc,1);self.assertEqual(count,0)
|
||||
self.assertEqual(receipt['reason'],'session_business_budget_expired')
|
||||
|
||||
def test_actual_main_complete_bounded_json_plus_eof_reaches_execution_once(self):
|
||||
stream=type('Input',(),{'buffer':io.BytesIO(PAYLOAD)})()
|
||||
rc,receipt,args,count=self.main_with(stream)
|
||||
self.assertEqual(rc,0);self.assertEqual(count,1)
|
||||
self.assertEqual(args.args[1],json.loads(PAYLOAD))
|
||||
self.assertTrue(args.kwargs['window_started'])
|
||||
self.assertNotIn('synthetic-human',json.dumps(receipt))
|
||||
|
||||
if __name__=='__main__':unittest.main(verbosity=2)
|
||||
|
|
@ -0,0 +1,165 @@
|
|||
"""Synthetic ready FD/control transport tests; no SSH/native/credentials."""
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import fcntl
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
spec=importlib.util.spec_from_file_location('routes',Path(__file__).with_name('test_openbao_node_route.py'))
|
||||
r=importlib.util.module_from_spec(spec);spec.loader.exec_module(r);s=r.s;f=r.f
|
||||
spec=importlib.util.spec_from_file_location('wrapper',Path(__file__).with_name('openbao-negative-node-wrapper.py'))
|
||||
w=importlib.util.module_from_spec(spec);spec.loader.exec_module(w)
|
||||
NONCE='a'*64
|
||||
|
||||
class ReadyTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
s.CLEANING=False;s.REQUEST_DEADLINE=None;s.BUSINESS_MONOTONIC_DEADLINE=None;s.CLEANUP_MONOTONIC_DEADLINE=None
|
||||
def marker(self,envelope=None):
|
||||
e=envelope or f.envelope()
|
||||
return {'schema':s.READY_SCHEMA,'session_nonce':NONCE,'proposal_id':s.PROPOSAL,'target':s.TARGET,
|
||||
'executor_sha256':s.digest(Path(s.__file__).read_bytes()),'envelope_sha256':'b'*64,
|
||||
'bao_health_identity_sha256':e['bao_health_identity_sha256'],'valid_until_epoch':e['valid_until_epoch'],
|
||||
'receive_budget_seconds':15}
|
||||
|
||||
def test_emit_exact_ready_marker_to_distinct_local_pipe_and_close_endpoint(self):
|
||||
e=f.envelope();s.start_execution_window(e);read_fd,write_fd=os.pipe();receipt={}
|
||||
s.emit_private_ready(write_fd,NONCE,'b'*64,'b'*64,e,receipt)
|
||||
raw=os.read(read_fd,4097);self.assertEqual(os.read(read_fd,1),b'');os.close(read_fd)
|
||||
obj=json.loads(raw)
|
||||
self.assertEqual(set(obj),set(self.marker(e)))
|
||||
self.assertEqual(obj['session_nonce'],NONCE)
|
||||
self.assertEqual(obj['executor_sha256'],s.digest(Path(s.__file__).read_bytes()))
|
||||
self.assertTrue(receipt['private_ready_emitted'])
|
||||
self.assertNotIn('human_token',obj);self.assertNotIn('coding_agent_token',obj)
|
||||
|
||||
def test_stdout_alias_pipe_fd_above_two_refused_without_marker(self):
|
||||
e=f.envelope();s.start_execution_window(e);read_fd,write_fd=os.pipe();saved=os.dup(1);alias=None
|
||||
try:
|
||||
os.dup2(write_fd,1);alias=os.dup(1)
|
||||
with self.assertRaisesRegex(s.Refused,'private_ready_stdio_alias_refused'):
|
||||
s.emit_private_ready(alias,NONCE,'b'*64,'b'*64,e,{})
|
||||
finally:
|
||||
os.dup2(saved,1);os.close(saved);os.close(write_fd)
|
||||
if alias is not None:os.close(alias)
|
||||
self.assertEqual(os.read(read_fd,4096),b'');os.close(read_fd)
|
||||
|
||||
def test_nonce_or_envelope_binding_failure_emits_no_ready(self):
|
||||
e=f.envelope();s.start_execution_window(e)
|
||||
for nonce,pin in [('wrong','b'*64),(NONCE,'c'*64)]:
|
||||
read_fd,write_fd=os.pipe()
|
||||
try:
|
||||
with self.assertRaises(s.Refused):s.emit_private_ready(write_fd,nonce,pin,'b'*64,e,{})
|
||||
finally:
|
||||
try:os.close(write_fd)
|
||||
except OSError:pass
|
||||
self.assertEqual(os.read(read_fd,4096),b'');os.close(read_fd)
|
||||
|
||||
def test_actual_full_ack_pipe_write_times_out_inside_original_window(self):
|
||||
e=f.envelope();s.start_execution_window(e)
|
||||
s.BUSINESS_MONOTONIC_DEADLINE=time.monotonic()+0.015
|
||||
read_fd,write_fd=os.pipe();fcntl.fcntl(write_fd,fcntl.F_SETPIPE_SZ,4096)
|
||||
flags=fcntl.fcntl(write_fd,fcntl.F_GETFL);fcntl.fcntl(write_fd,fcntl.F_SETFL,flags|os.O_NONBLOCK)
|
||||
try:
|
||||
while True:os.write(write_fd,b'x'*4096)
|
||||
except BlockingIOError:pass
|
||||
fcntl.fcntl(write_fd,fcntl.F_SETFL,flags)
|
||||
started=time.monotonic();handler=signal.getsignal(signal.SIGALRM)
|
||||
with self.assertRaisesRegex(s.Refused,'request_total_timeout'):
|
||||
s.emit_private_ready(write_fd,NONCE,'b'*64,'b'*64,e,{})
|
||||
self.assertLess(time.monotonic()-started,0.15);os.close(read_fd)
|
||||
self.assertEqual(signal.getsignal(signal.SIGALRM),handler)
|
||||
self.assertEqual(signal.getitimer(signal.ITIMER_REAL),(0.0,0.0))
|
||||
|
||||
def test_actual_main_health_ready_then_private_read_exact_order(self):
|
||||
previous=signal.getsignal(signal.SIGTERM);read_fd,write_fd=os.pipe();events=[];received={}
|
||||
try:
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
e=f.envelope();path=Path(folder)/'approved.json';path.write_text(json.dumps(e));path.chmod(0o600)
|
||||
pin=s.digest(path.read_bytes());receipt=Path(folder)/'receipt.json'
|
||||
argv=['session','execute','--envelope',str(path),'--receipt',str(receipt),'--ready-fd',str(write_fd),
|
||||
'--session-nonce',NONCE,'--ready-envelope-sha256',pin]
|
||||
class Input:
|
||||
first=True
|
||||
def read(self,size):
|
||||
if not self.first:return b''
|
||||
self.first=False
|
||||
received.update(json.loads(os.read(read_fd,4096)))
|
||||
self_test.assertEqual(received['envelope_sha256'],pin)
|
||||
events.append('private-read')
|
||||
return b'{"human_token":"synthetic","coding_agent_token":"synthetic"}'
|
||||
self_test=self;stream=type('Stream',(),{'buffer':Input()})()
|
||||
def health():events.append('health');return e['bao_health_identity_sha256']
|
||||
def execute(*args,**kwargs):events.append('execute');self.assertTrue(kwargs['window_started'])
|
||||
with patch.object(sys,'argv',argv),patch.object(sys,'stdin',stream), \
|
||||
patch.object(s,'SessionTransport') as factory,patch.object(s,'execute',side_effect=execute),patch.object(s,'k') as k:
|
||||
factory.return_value.health_identity.side_effect=health
|
||||
self.assertEqual(s.main(),0);k.assert_not_called()
|
||||
self.assertEqual(events,['health','private-read','execute'])
|
||||
self.assertEqual(received['session_nonce'],NONCE)
|
||||
finally:
|
||||
signal.signal(signal.SIGTERM,previous);os.close(read_fd)
|
||||
try:os.close(write_fd)
|
||||
except OSError:pass
|
||||
|
||||
def test_actual_main_missing_ack_fd_never_reads_private_input(self):
|
||||
previous=signal.getsignal(signal.SIGTERM)
|
||||
try:
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
e=f.envelope();path=Path(folder)/'approved.json';path.write_text(json.dumps(e));path.chmod(0o600)
|
||||
receipt=Path(folder)/'receipt.json';argv=['session','execute','--envelope',str(path),'--receipt',str(receipt)]
|
||||
with patch.object(sys,'argv',argv),patch.object(sys,'stdin') as stdin,patch.object(s,'SessionTransport') as factory,patch.object(s,'k') as k:
|
||||
factory.return_value.health_identity.return_value=e['bao_health_identity_sha256']
|
||||
self.assertEqual(s.main(),1);stdin.buffer.read.assert_not_called();k.assert_not_called()
|
||||
self.assertEqual(json.loads(receipt.read_text())['reason'],'private_ready_pipe_required')
|
||||
finally:signal.signal(signal.SIGTERM,previous)
|
||||
|
||||
def test_wrapper_marker_pin_nonce_fields_and_window_failures_refuse(self):
|
||||
e=f.envelope();marker=self.marker(e)
|
||||
for key,value in [('session_nonce','c'*64),('executor_sha256','c'*64),('envelope_sha256','c'*64),
|
||||
('target','other'),('proposal_id','other'),('bao_health_identity_sha256','c'*64),
|
||||
('valid_until_epoch',True),('receive_budget_seconds',True),('receive_budget_seconds',float('inf'))]:
|
||||
bad=dict(marker,**{key:value})
|
||||
with self.subTest(key=key),self.assertRaises(w.Refused):
|
||||
w.validate_marker(json.dumps(bad).encode()+b'\n',nonce=NONCE,executor_pin=marker['executor_sha256'],envelope_pin='b'*64,envelope=e)
|
||||
with self.assertRaises(w.Refused):
|
||||
w.validate_marker(json.dumps(dict(marker,extra=True)).encode()+b'\n',nonce=NONCE,executor_pin=marker['executor_sha256'],envelope_pin='b'*64,envelope=e)
|
||||
|
||||
def test_wrapper_ready_pipe_no_eof_is_bounded(self):
|
||||
read_fd,write_fd=os.pipe();os.write(write_fd,b'{"incomplete":true}')
|
||||
started=time.monotonic()
|
||||
try:
|
||||
with self.assertRaisesRegex(w.Refused,'wrapper_timeout'):
|
||||
with w.deadline(0.015):w.read_ready(read_fd)
|
||||
finally:os.close(write_fd);os.close(read_fd)
|
||||
self.assertLess(time.monotonic()-started,0.15)
|
||||
|
||||
def test_wrapper_pinned_node_child_launch_is_local_pipe_not_ssh_fd_forward(self):
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
child=Path(folder)/'child.py';child.write_text('# synthetic child, never executed\n')
|
||||
envelope=f.envelope();envelope['source_hashes']={str(child):w.hash_bytes(child.read_bytes()),str(Path(w.__file__).resolve()):w.hash_bytes(Path(w.__file__).read_bytes())}
|
||||
env=Path(folder)/'approved.json';env.write_text(json.dumps(envelope));env.chmod(0o600)
|
||||
args=type('Args',(),{'child':str(child),'child_sha256':w.hash_bytes(child.read_bytes()),'envelope':str(env),'envelope_sha256':w.hash_bytes(env.read_bytes()),'receipt':str(Path(folder)/'receipt.json'),'session_nonce':NONCE})()
|
||||
class Process:
|
||||
stdout=io.BytesIO();stderr=io.BytesIO()
|
||||
def wait(self,timeout):return 0
|
||||
def poll(self):return 0
|
||||
def launch(command,**kwargs):
|
||||
self.assertEqual(command[0],sys.executable)
|
||||
self.assertEqual(command[1],str(child));self.assertNotIn('ssh',command)
|
||||
self.assertEqual(kwargs['stdin'],sys.stdin.buffer)
|
||||
fd=int(command[command.index('--ready-fd')+1]);self.assertEqual(kwargs['pass_fds'],(fd,))
|
||||
marker=self.marker(envelope);marker.update(executor_sha256=args.child_sha256,envelope_sha256=args.envelope_sha256)
|
||||
os.write(fd,json.dumps(marker).encode()+b'\n')
|
||||
return Process()
|
||||
with patch.object(w.subprocess,'Popen',side_effect=launch),patch.object(w,'forward_ready') as forward:
|
||||
self.assertEqual(w.run(args),0);self.assertEqual(forward.call_count,1)
|
||||
self.assertNotIn('human_token',json.loads(forward.call_args.args[0]))
|
||||
|
||||
if __name__=='__main__':unittest.main(verbosity=2)
|
||||
|
|
@ -0,0 +1,156 @@
|
|||
import base64
|
||||
import contextlib
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
'openbao_signin_diagnosis_rollout', Path(__file__).with_name('openbao-signin-diagnosis-rollout.py'))
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
UID = '00000000-0000-4000-8000-000000000001'
|
||||
CLUSTER = {'metadata': {'uid': module.lane.CLUSTER_UID}}
|
||||
OPENBAO = (' - clientId: openbao-admin\n'
|
||||
' displayName: Railiance OpenBao Admin\n'
|
||||
' redirectUris:\n'
|
||||
' - http://localhost:8250/oidc/callback\n'
|
||||
' clientType: public\n')
|
||||
OTHER = (' - clientId: user-engine-portal\n'
|
||||
' clientType: public\n')
|
||||
|
||||
|
||||
def fixture(clients=OTHER + OPENBAO, tail='tokenLifetime: 15m\n'):
|
||||
# Synthetic marker values only; no live Secret is loaded by this suite.
|
||||
raw = ('# preserve this comment\nissuer: https://kc.coulomb.social\n'
|
||||
'authelia:\n issuer: https://auth.coulomb.social\n clientSecret: synthetic\n'
|
||||
'clients:\n' + clients + tail)
|
||||
secret = {'metadata': {'uid': UID, 'resourceVersion': '7'},
|
||||
'data': {'config.yaml': base64.b64encode(raw.encode()).decode(),
|
||||
'signing-key.pem': base64.b64encode(b'synthetic').decode()}}
|
||||
return secret, raw
|
||||
|
||||
|
||||
def decode(encoded):
|
||||
return base64.b64decode(encoded).decode()
|
||||
|
||||
|
||||
class EditTests(unittest.TestCase):
|
||||
def test_enable_adds_exactly_one_line_after_client_id(self):
|
||||
secret, raw = fixture()
|
||||
encoded, changed = module.enable(secret)
|
||||
self.assertTrue(changed)
|
||||
expected = raw.replace(' - clientId: openbao-admin\n',
|
||||
' - clientId: openbao-admin\n signInDiagnosis: true\n')
|
||||
self.assertEqual(decode(encoded), expected)
|
||||
|
||||
def test_enable_is_noop_when_already_true(self):
|
||||
secret, _ = fixture(OPENBAO.replace('clientType: public', 'clientType: public\n signInDiagnosis: true'))
|
||||
encoded, changed = module.enable(secret)
|
||||
self.assertFalse(changed)
|
||||
self.assertEqual(encoded, secret['data']['config.yaml'])
|
||||
|
||||
def test_other_flag_value_fails_closed(self):
|
||||
secret, _ = fixture(OPENBAO + ' signInDiagnosis: false\n')
|
||||
for operation in (module.enable, module.disable):
|
||||
with self.assertRaises(module.Refused):
|
||||
operation(secret)
|
||||
|
||||
def test_missing_or_duplicate_client_fails_closed(self):
|
||||
for clients in (OTHER, OPENBAO + OPENBAO):
|
||||
with self.assertRaises(module.Refused):
|
||||
module.enable(fixture(clients)[0])
|
||||
|
||||
def test_unverified_upstream_issuer_refused(self):
|
||||
secret, raw = fixture()
|
||||
raw = raw.replace('https://auth.coulomb.social', 'https://evil.test')
|
||||
secret['data']['config.yaml'] = base64.b64encode(raw.encode()).decode()
|
||||
with self.assertRaises(module.Refused):
|
||||
module.enable(secret)
|
||||
|
||||
def test_rollback_restores_exact_bytes(self):
|
||||
secret, raw = fixture()
|
||||
enabled, _ = module.enable(secret)
|
||||
secret['data']['config.yaml'] = enabled
|
||||
restored, changed = module.disable(secret)
|
||||
self.assertTrue(changed)
|
||||
self.assertEqual(decode(restored), raw)
|
||||
|
||||
def test_rollback_absent_is_noop(self):
|
||||
secret, _ = fixture()
|
||||
encoded, changed = module.disable(secret)
|
||||
self.assertFalse(changed)
|
||||
self.assertEqual(encoded, secret['data']['config.yaml'])
|
||||
|
||||
|
||||
class RunTests(unittest.TestCase):
|
||||
"""Drive run() against a fake kubectl; nothing reaches a cluster."""
|
||||
|
||||
def setUp(self):
|
||||
self.secret, _ = fixture()
|
||||
self.calls = []
|
||||
|
||||
def fake(self, arguments, payload=None):
|
||||
self.calls.append((arguments, payload))
|
||||
if arguments[:2] == ['get', 'namespace']:
|
||||
return CLUSTER
|
||||
if 'patch' in arguments:
|
||||
data = dict(self.secret['data'], **{'config.yaml': payload[-1]['value']})
|
||||
if '--dry-run=server' not in arguments:
|
||||
self.secret = {'metadata': {'uid': UID, 'resourceVersion': '8'}, 'data': data}
|
||||
return {'data': data}
|
||||
return self.secret
|
||||
|
||||
def main(self, *argv):
|
||||
out = io.StringIO()
|
||||
with contextlib.redirect_stdout(out):
|
||||
code = module.main(list(argv), kubectl=self.fake)
|
||||
return code, out.getvalue()
|
||||
|
||||
def patches(self):
|
||||
return [c for c in self.calls if 'patch' in c[0]]
|
||||
|
||||
def test_default_is_inspect_and_never_patches(self):
|
||||
code, out = self.main()
|
||||
self.assertEqual(code, 0)
|
||||
receipt = json.loads(out)
|
||||
self.assertTrue(receipt['change_needed'])
|
||||
self.assertEqual(receipt['before'], {'uid': UID, 'resource_version': '7'})
|
||||
self.assertEqual(self.patches(), [])
|
||||
self.assertNotIn('synthetic', out)
|
||||
|
||||
def test_dry_run_requires_observed_revision_and_is_server_side(self):
|
||||
code, out = self.main('dry-run')
|
||||
self.assertEqual((code, json.loads(out)['reason']), (1, 'observed_revision_changed'))
|
||||
code, out = self.main('dry-run', '--expected-uid', UID, '--expected-resource-version', '7')
|
||||
self.assertEqual(code, 0)
|
||||
self.assertIn('--dry-run=server', self.patches()[-1][0])
|
||||
self.assertNotIn('synthetic', out)
|
||||
|
||||
def test_apply_requires_attended_and_reports_restart(self):
|
||||
code, out = self.main('apply', '--expected-uid', UID, '--expected-resource-version', '7')
|
||||
self.assertEqual((code, json.loads(out)['reason']), (1, 'attended_operator_required'))
|
||||
self.assertEqual(self.patches(), [])
|
||||
code, out = self.main('apply', '--attended', '--expected-uid', UID, '--expected-resource-version', '7')
|
||||
receipt = json.loads(out)
|
||||
self.assertEqual(code, 0)
|
||||
self.assertTrue(receipt['changed'] and receipt['restart_required'])
|
||||
self.assertEqual(receipt['after']['resource_version'], '8')
|
||||
self.assertEqual(self.patches()[-1][1][:2], [
|
||||
{'op': 'test', 'path': '/metadata/uid', 'value': UID},
|
||||
{'op': 'test', 'path': '/metadata/resourceVersion', 'value': '7'}])
|
||||
self.assertNotIn('synthetic', out)
|
||||
|
||||
def test_failures_never_print_secret_material(self):
|
||||
def broken(arguments, payload=None):
|
||||
raise RuntimeError('synthetic value leaked in error text')
|
||||
out = io.StringIO()
|
||||
with contextlib.redirect_stdout(out):
|
||||
self.assertEqual(module.main([], kubectl=broken), 1)
|
||||
self.assertNotIn('synthetic', out.getvalue())
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
|
@ -0,0 +1,228 @@
|
|||
import base64
|
||||
import copy
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
import jsonpatch
|
||||
|
||||
ROOT = Path(__file__).parent
|
||||
def load(name, filename):
|
||||
spec = importlib.util.spec_from_file_location(name, ROOT / filename)
|
||||
module = importlib.util.module_from_spec(spec); spec.loader.exec_module(module)
|
||||
return module
|
||||
a = load('activation', 'playwright-activation.py')
|
||||
f = load('registration_fixture', 'test_playwright_registration.py')
|
||||
REVISION = '5dd74dfbdf3fd38adb1bfc1bdc1326181435f0e9'
|
||||
IDENTITIES = [f.IDENTITY, dict(f.IDENTITY, agent_id='second', service_subject='service:second')]
|
||||
|
||||
|
||||
def fixtures():
|
||||
secret, raw = f.fixture()
|
||||
secret.update(kind='Secret', apiVersion='v1')
|
||||
secret['metadata'].update(name='keycape-config', namespace='sso', uid='00000000-0000-4000-8000-000000000001')
|
||||
deployment = {'apiVersion': 'apps/v1', 'kind': 'Deployment', 'metadata': {
|
||||
'name': 'keycape', 'namespace': 'sso', 'uid': '00000000-0000-4000-8000-000000000002',
|
||||
'resourceVersion': '12', 'generation': 5}, 'spec': {'replicas': 1, 'strategy': {'type': 'Recreate'},
|
||||
'template': {'metadata': {'labels': {'app': 'keycape'}}, 'spec': {
|
||||
'containers': [{'name': 'keycape', 'image': 'forgejo.coulomb.social/coulomb/key-cape@sha256:' + 'a' * 64,
|
||||
'env': [{'name': 'KEYCAPE_CONFIG', 'value': '/etc/keycape/config.yaml'}],
|
||||
'volumeMounts': [{'name': 'registry', 'mountPath': '/etc/keycape', 'readOnly': True}]}],
|
||||
'volumes': [{'name': 'registry', 'secret': {'secretName': 'keycape-config'}}]}}}}
|
||||
controller(deployment)
|
||||
return secret, deployment
|
||||
|
||||
|
||||
def controller(deployment):
|
||||
# Simulates observed controller completion, separate from patch admission.
|
||||
replicas = deployment['spec']['replicas']
|
||||
deployment['status'] = {'observedGeneration': deployment['metadata']['generation'],
|
||||
**{k: replicas for k in ['replicas', 'readyReplicas', 'availableReplicas', 'updatedReplicas']}}
|
||||
|
||||
|
||||
class World:
|
||||
def __init__(self, secret, deployment):
|
||||
self.secret, self.deployment = copy.deepcopy(secret), copy.deepcopy(deployment)
|
||||
self.pods = {'kind': 'PodList', 'metadata': {}, 'items': []}
|
||||
self.history = []
|
||||
|
||||
def step(self, proposal, phase, observe=True):
|
||||
operation = a.propose_step(proposal, a.digest(proposal), phase, self.secret, self.deployment, self.pods)
|
||||
if operation is None: return None
|
||||
attr = 'secret' if operation['kind'] == 'Secret' else 'deployment'
|
||||
# Independent RFC 6902 implementation verifies actual generated CAS tests.
|
||||
updated = jsonpatch.apply_patch(getattr(self, attr), operation['patch'], in_place=False)
|
||||
updated['metadata']['resourceVersion'] = str(int(updated['metadata']['resourceVersion']) + 1)
|
||||
if attr == 'deployment': updated['metadata']['generation'] += 1
|
||||
self.history.append(phase)
|
||||
setattr(self, attr, updated)
|
||||
receipt = a.verify_response(operation, updated)
|
||||
if attr == 'deployment' and observe: controller(updated)
|
||||
return receipt
|
||||
|
||||
|
||||
class ActivationTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.secret, self.deployment = fixtures()
|
||||
self.proposal = a.build(self.secret, self.deployment, IDENTITIES, REVISION)
|
||||
|
||||
def test_exact_sequence_keeps_original_source_and_returns_safe_receipts(self):
|
||||
world = World(self.secret, self.deployment)
|
||||
original = copy.deepcopy((self.secret, self.deployment))
|
||||
for phase in ['stop', 'registry', 'bindings', 'resume']:
|
||||
receipt = world.step(self.proposal, phase)
|
||||
self.assertTrue(receipt['readback_matches'])
|
||||
self.assertNotIn('synthetic-private-marker', json.dumps(receipt))
|
||||
self.assertEqual(world.deployment['spec']['replicas'], 1)
|
||||
self.assertEqual(world.secret['data'], self.proposal['after_data'])
|
||||
self.assertEqual((self.secret, self.deployment), original)
|
||||
self.assertNotIn('synthetic-private-marker', json.dumps(a.summary(self.proposal)))
|
||||
|
||||
def test_rollback_preserves_preexisting_public_and_agent_registrations(self):
|
||||
encoded, _ = a.registration.prepare_registry(self.secret, [IDENTITIES[0]])
|
||||
self.secret['data']['config.yaml'] = encoded
|
||||
binding = a.registration.registry.env_binding(a.registration.agent_client(IDENTITIES[0]))
|
||||
a.container(self.deployment['spec'])['env'].append(binding)
|
||||
proposal = a.build(self.secret, self.deployment, IDENTITIES, REVISION)
|
||||
self.assertEqual(proposal['created_client_ids'], ['playwright-agent-second'])
|
||||
world = World(self.secret, self.deployment)
|
||||
for phase in ['stop', 'registry', 'bindings', 'resume', 'rollback-stop',
|
||||
'rollback-registry', 'rollback-bindings', 'rollback-resume']:
|
||||
world.step(proposal, phase)
|
||||
self.assertEqual(world.secret['data'], self.secret['data'])
|
||||
self.assertEqual(world.deployment['spec'], self.deployment['spec'])
|
||||
|
||||
def test_each_partial_state_recovers_without_removing_original_entries(self):
|
||||
phases = ['stop', 'registry', 'bindings', 'resume']
|
||||
for count in range(5):
|
||||
world = World(self.secret, self.deployment)
|
||||
for phase in phases[:count]: world.step(self.proposal, phase)
|
||||
for phase in ['rollback-stop', 'rollback-registry', 'rollback-bindings', 'rollback-resume']:
|
||||
world.step(self.proposal, phase)
|
||||
self.assertEqual(world.secret['data'], self.secret['data'])
|
||||
self.assertEqual(world.deployment['spec'], self.deployment['spec'])
|
||||
|
||||
def test_identical_complete_registration_is_noop_without_outage(self):
|
||||
world = World(self.secret, self.deployment)
|
||||
for phase in ['stop', 'registry', 'bindings', 'resume']: world.step(self.proposal, phase)
|
||||
proposal = a.build(world.secret, world.deployment, IDENTITIES, REVISION)
|
||||
self.assertFalse(a.summary(proposal)['change_needed'])
|
||||
before = len(world.history)
|
||||
for phase in ['stop', 'registry', 'bindings', 'resume']: self.assertIsNone(world.step(proposal, phase))
|
||||
self.assertEqual(len(world.history), before)
|
||||
|
||||
def test_rollback_before_any_execution_never_stops_the_issuer(self):
|
||||
world = World(self.secret, self.deployment)
|
||||
for phase in ['rollback-stop', 'rollback-registry', 'rollback-bindings', 'rollback-resume']:
|
||||
self.assertIsNone(world.step(self.proposal, phase))
|
||||
self.assertEqual(world.history, [])
|
||||
|
||||
def test_reordered_phases_and_unobserved_controller_are_refused(self):
|
||||
world = World(self.secret, self.deployment)
|
||||
for phase in ['registry', 'bindings', 'resume']:
|
||||
with self.subTest(phase=phase), self.assertRaises(a.Refused): world.step(self.proposal, phase)
|
||||
world.step(self.proposal, 'stop', observe=False)
|
||||
with self.assertRaises(a.Refused): world.step(self.proposal, 'registry')
|
||||
controller(world.deployment)
|
||||
with self.assertRaises(a.Refused): world.step(self.proposal, 'resume')
|
||||
|
||||
def test_terminating_direct_projected_and_env_consumers_prevent_registry_write(self):
|
||||
specs = [{'volumes': [{'secret': {'secretName': 'keycape-config'}}]},
|
||||
{'volumes': [{'projected': {'sources': [{'secret': {'name': 'keycape-config'}}]}}]},
|
||||
{'containers': [{'envFrom': [{'secretRef': {'name': 'keycape-config'}}]}]},
|
||||
{'initContainers': [{'env': [{'valueFrom': {'secretKeyRef': {'name': 'keycape-config'}}}]}]}]
|
||||
for spec in specs:
|
||||
world = World(self.secret, self.deployment); world.step(self.proposal, 'stop')
|
||||
world.pods['items'] = [{'metadata': {'namespace': 'sso', 'deletionTimestamp': 'synthetic'}, 'spec': spec}]
|
||||
with self.subTest(spec=spec), self.assertRaises(a.Refused): world.step(self.proposal, 'registry')
|
||||
|
||||
def test_partial_pod_inventory_refused(self):
|
||||
world = World(self.secret, self.deployment); world.step(self.proposal, 'stop')
|
||||
world.pods['metadata']['continue'] = 'another-page'
|
||||
with self.assertRaises(a.Refused): world.step(self.proposal, 'registry')
|
||||
|
||||
def test_original_revision_and_proposal_digest_are_bound(self):
|
||||
for attr in ['secret', 'deployment']:
|
||||
world = World(self.secret, self.deployment)
|
||||
getattr(world, attr)['metadata']['resourceVersion'] = '999'
|
||||
with self.subTest(attr=attr), self.assertRaises(a.Refused): world.step(self.proposal, 'stop')
|
||||
changed = copy.deepcopy(self.proposal); changed['after_data']['other'] = 'changed'
|
||||
with self.assertRaises(a.Refused):
|
||||
a.propose_step(changed, a.digest(self.proposal), 'stop', self.secret, self.deployment, {'kind': 'PodList', 'items': []})
|
||||
|
||||
def test_independent_jsonpatch_rejects_racing_version_or_spec(self):
|
||||
operation = a.propose_step(self.proposal, a.digest(self.proposal), 'stop',
|
||||
self.secret, self.deployment, {'kind': 'PodList', 'items': []})
|
||||
for field in ['version', 'spec']:
|
||||
raced = copy.deepcopy(self.deployment)
|
||||
if field == 'version': raced['metadata']['resourceVersion'] = '999'
|
||||
else: raced['spec']['replicas'] = 2
|
||||
with self.subTest(field=field), self.assertRaises(jsonpatch.JsonPatchTestFailed):
|
||||
jsonpatch.apply_patch(raced, operation['patch'])
|
||||
|
||||
def test_private_registry_patch_checks_complete_data_and_uid(self):
|
||||
world = World(self.secret, self.deployment); world.step(self.proposal, 'stop')
|
||||
operation = a.propose_step(self.proposal, a.digest(self.proposal), 'registry', world.secret, world.deployment, world.pods)
|
||||
for field in ['data', 'uid']:
|
||||
raced = copy.deepcopy(world.secret)
|
||||
if field == 'data': raced['data']['other'] = 'racing-write'
|
||||
else: raced['metadata']['uid'] = 'recreated'
|
||||
with self.subTest(field=field), self.assertRaises(jsonpatch.JsonPatchTestFailed):
|
||||
jsonpatch.apply_patch(raced, operation['patch'])
|
||||
|
||||
def test_unrelated_registry_or_deployment_drift_refuses_rollback(self):
|
||||
for field in ['data', 'spec']:
|
||||
world = World(self.secret, self.deployment)
|
||||
for phase in ['stop', 'registry', 'bindings']: world.step(self.proposal, phase)
|
||||
if field == 'data': world.secret['data']['unrelated-new-field'] = 'keep'
|
||||
else: world.deployment['spec']['template']['metadata']['labels']['new'] = 'keep'
|
||||
with self.subTest(field=field), self.assertRaises(a.Refused): world.step(self.proposal, 'rollback-registry')
|
||||
|
||||
def test_readback_mismatch_refused_without_raw_output(self):
|
||||
operation = a.propose_step(self.proposal, a.digest(self.proposal), 'stop', self.secret, self.deployment, {})
|
||||
with self.assertRaisesRegex(a.Refused, '^mutation_readback_mismatch$'):
|
||||
a.verify_response(operation, self.deployment)
|
||||
|
||||
def test_unsupported_deployment_and_binding_drift_refused(self):
|
||||
changes = [lambda d: d['spec'].update(replicas=2),
|
||||
lambda d: d['spec']['strategy'].update(type='RollingUpdate'),
|
||||
lambda d: a.container(d['spec']).update(image='unreviewed:latest'),
|
||||
lambda d: a.container(d['spec']).update(envFrom=[{'secretRef': {'name': 'other'}}]),
|
||||
lambda d: a.container(d['spec'])['env'].append({'name': 'KEYCAPE_CONFIG', 'value': 'other'}),
|
||||
lambda d: a.container(d['spec'])['env'].append({'name': 'KEYCAPE_PLAYWRIGHT_AGENT_TEST_AGENT_CLIENT_SECRET', 'value': 'synthetic-private-marker'})]
|
||||
for change in changes:
|
||||
deployment = copy.deepcopy(self.deployment); change(deployment)
|
||||
with self.subTest(change=change), self.assertRaises(a.Refused):
|
||||
a.build(self.secret, deployment, IDENTITIES, REVISION)
|
||||
|
||||
def test_native_verifier_is_pinned_contained_and_ignores_ambient_environment(self):
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
binary = Path(folder) / 'verifier'; binary.write_bytes(b'synthetic-binary')
|
||||
sha = hashlib.sha256(binary.read_bytes()).hexdigest()
|
||||
with patch.object(a.subprocess, 'run') as run:
|
||||
run.return_value = subprocess.CompletedProcess([], 0, b'{"valid": true, "errors": []}', b'')
|
||||
self.assertTrue(a.check_registry(self.proposal, binary, sha)['proposed_registry_valid'])
|
||||
self.assertEqual(run.call_count, 2)
|
||||
self.assertEqual(run.call_args.kwargs['env'], {'PATH': '/usr/bin:/bin'})
|
||||
self.assertIn('synthetic-private-marker', run.call_args.kwargs['input'].decode())
|
||||
self.assertNotIn('synthetic-private-marker', str(run.call_args.args))
|
||||
run.return_value = subprocess.CompletedProcess([], 1, b'{"valid": false, "errors": ["synthetic-private-marker"]}', b'synthetic-private-marker')
|
||||
with self.assertRaisesRegex(a.Refused, '^native_registry_check_failed$'):
|
||||
a.check_registry(self.proposal, binary, sha)
|
||||
with self.assertRaisesRegex(a.Refused, '^native_verifier_digest_mismatch$'):
|
||||
a.check_registry(self.proposal, binary, 'wrong')
|
||||
|
||||
def test_other_env_name_cannot_alias_a_proposed_agent_secret(self):
|
||||
binding = a.registration.registry.env_binding(a.registration.agent_client(IDENTITIES[0]))
|
||||
binding['name'] = 'OTHER_CLIENT_SECRET'
|
||||
a.container(self.deployment['spec'])['env'].append(binding)
|
||||
with self.assertRaisesRegex(a.Refused, 'delivery_secret_already_bound'):
|
||||
a.build(self.secret, self.deployment, IDENTITIES, REVISION)
|
||||
|
||||
|
||||
if __name__ == '__main__': unittest.main()
|
||||
|
|
@ -0,0 +1,158 @@
|
|||
import base64
|
||||
import contextlib
|
||||
import copy
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
import yaml
|
||||
|
||||
spec = importlib.util.spec_from_file_location('playwright_registration', Path(__file__).with_name('playwright-registration.py'))
|
||||
p = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(p)
|
||||
IDENTITY = {'agent_id': 'test-agent', 'service_subject': 'service:test-agent:platform',
|
||||
'tenant': 'tenant:test', 'identity_record': 'synthetic/agent-record.json'}
|
||||
|
||||
|
||||
def fixture():
|
||||
raw = ('# preserve the issuer and existing clients\nissuer: https://kc.coulomb.social\n'
|
||||
'port: 8080\nprivateKeyPem: /synthetic/key.pem\ntokenLifetime: 15m\n'
|
||||
'authelia:\n issuer: https://auth.coulomb.social\n clientSecret: synthetic-private-marker\n'
|
||||
'clients:\n - clientId: existing\n clientType: public\n'
|
||||
' redirectUris: [https://example.test/callback]\n'
|
||||
' allowedScopes: [openid]\n grantTypes: [authorization_code]\n'
|
||||
'# trailing configuration comment\nlogLevel: info\n')
|
||||
return {'metadata': {'uid': 'synthetic', 'resourceVersion': '7'},
|
||||
'data': {'config.yaml': base64.b64encode(raw.encode()).decode(), 'other': 'preserve'}}, raw
|
||||
|
||||
|
||||
def decode(value):
|
||||
return base64.b64decode(value).decode()
|
||||
|
||||
|
||||
def altered(secret, change):
|
||||
result = copy.deepcopy(secret)
|
||||
config = yaml.safe_load(decode(result['data']['config.yaml']))
|
||||
change(config)
|
||||
result['data']['config.yaml'] = base64.b64encode(yaml.safe_dump(config, sort_keys=False).encode()).decode()
|
||||
return result
|
||||
|
||||
|
||||
class RegistrationTests(unittest.TestCase):
|
||||
def test_public_matches_platform_and_agent_has_distinct_delivery(self):
|
||||
client = p.public_client()
|
||||
self.assertEqual(client['redirectUris'], ['http://127.0.0.1:18764/callback'])
|
||||
self.assertNotIn('tokenLifetime', client)
|
||||
self.assertNotIn('secretRef', client)
|
||||
agent = p.agent_client(IDENTITY)
|
||||
self.assertEqual(agent['allowedScopes'], ['playwright:connect'])
|
||||
self.assertEqual(agent['roles'], ['coding-agent'])
|
||||
self.assertEqual(agent['tenant'], IDENTITY['tenant'])
|
||||
self.assertEqual(agent['serviceSubject'], IDENTITY['service_subject'])
|
||||
self.assertEqual(agent['tokenLifetime'], '15m')
|
||||
self.assertEqual(p.registry.env_binding(agent)['valueFrom']['secretKeyRef'],
|
||||
{'name': 'keycape-playwright-agent-test-agent-client', 'key': 'client-secret'})
|
||||
|
||||
def test_unknown_or_missing_identity_fields_refused(self):
|
||||
for identity in [{}, [], dict(IDENTITY, secret='not-emitted'), dict(IDENTITY, tenant=''),
|
||||
dict(IDENTITY, service_subject='human:someone'),
|
||||
dict(IDENTITY, identity_record='bad\nreference'),
|
||||
dict(IDENTITY, agent_id='bad_$agent'), dict(IDENTITY, tenant=True)]:
|
||||
with self.subTest(identity=identity), self.assertRaises(p.Refused):
|
||||
p.agent_client(identity)
|
||||
|
||||
def test_duplicate_agent_or_subject_refused(self):
|
||||
for second in [IDENTITY, dict(IDENTITY, agent_id='second'),
|
||||
dict(IDENTITY, service_subject='service:second')]:
|
||||
with self.subTest(second=second), self.assertRaises(p.Refused):
|
||||
p.clients([IDENTITY, second])
|
||||
|
||||
def test_go_lifetimes_are_bounded_without_global_change(self):
|
||||
for value in ['15m', '1h', '1h0m0s', '3599.5s', '1000ms', '1.5m', '.5m']:
|
||||
with self.subTest(value=value): p.human_lifetime({'tokenLifetime': value})
|
||||
for value in [None, 900, '', '0s', '-1m', '1h1ns', '0.5s', 'NaN', '15mgarbage', '1d']:
|
||||
with self.subTest(value=value), self.assertRaises(p.Refused):
|
||||
p.human_lifetime({'tokenLifetime': value})
|
||||
|
||||
def test_add_preserves_bytes_and_is_repeatable_then_exact_remove(self):
|
||||
secret, raw = fixture()
|
||||
original = copy.deepcopy(secret)
|
||||
encoded, changed = p.prepare_registry(secret, [IDENTITY])
|
||||
self.assertEqual(changed, ['playwright-cli', 'playwright-agent-test-agent'])
|
||||
updated = decode(encoded)
|
||||
prefix, suffix = raw.split('logLevel:')
|
||||
self.assertTrue(updated.startswith(prefix))
|
||||
self.assertTrue(updated.endswith('logLevel:' + suffix))
|
||||
self.assertEqual(secret, original)
|
||||
current = copy.deepcopy(secret); current['data']['config.yaml'] = encoded
|
||||
self.assertEqual(p.prepare_registry(current, [IDENTITY]), (encoded, []))
|
||||
restored, removed = p.prepare_registry(current, [IDENTITY], 'remove')
|
||||
self.assertEqual(decode(restored), raw)
|
||||
self.assertEqual(removed, changed)
|
||||
self.assertEqual(p.prepare_registry(secret, [IDENTITY], 'remove'),
|
||||
(secret['data']['config.yaml'], []))
|
||||
|
||||
def test_refuses_drift_conflicts_and_unreviewed_audience(self):
|
||||
base, _ = fixture()
|
||||
public = p.public_client(); agent = p.agent_client(IDENTITY)
|
||||
cases = [lambda c: c.update(issuer='https://other.test'),
|
||||
lambda c: c.update(tokenLifetime='2h'),
|
||||
lambda c: c['clients'].append(dict(public, allowedScopes=['openid'])),
|
||||
lambda c: c['clients'].append(dict(public, clientId='other-consumer')),
|
||||
lambda c: c['clients'].append(dict(agent, clientId='duplicate-subject')),
|
||||
lambda c: c['clients'].append({'clientId': 'other', 'secretRef': agent['secretRef']}),
|
||||
lambda c: c['clients'].append(copy.deepcopy(c['clients'][0]))]
|
||||
for change in cases:
|
||||
for operation in ['add', 'remove']:
|
||||
with self.subTest(change=change, operation=operation), self.assertRaises(p.Refused):
|
||||
p.prepare_registry(altered(base, change), [IDENTITY], operation)
|
||||
|
||||
def test_same_agent_can_keep_separate_existing_openbao_client(self):
|
||||
base, _ = fixture()
|
||||
existing = dict(p.agent_client(IDENTITY), clientId='existing-openbao-agent',
|
||||
audience='openbao', allowedScopes=['openbao:login'],
|
||||
secretRef='env:EXISTING_OPENBAO_SECRET')
|
||||
secret = altered(base, lambda c: c['clients'].append(existing))
|
||||
encoded, _ = p.prepare_registry(secret, [IDENTITY])
|
||||
self.assertIn(existing, yaml.safe_load(decode(encoded))['clients'])
|
||||
|
||||
def test_default_audience_client_id_cannot_bypass_dedicated_inventory(self):
|
||||
base, _ = fixture()
|
||||
default_audience = dict(p.public_client(), clientId=p.AUDIENCE)
|
||||
del default_audience['audience']
|
||||
secret = altered(base, lambda c: c['clients'].append(default_audience))
|
||||
with self.assertRaises(p.Refused):
|
||||
p.prepare_registry(secret, [IDENTITY])
|
||||
|
||||
def test_cli_outputs_only_proposal_and_no_false_admission(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / 'agents.json'; path.write_text(json.dumps([IDENTITY]))
|
||||
out = io.StringIO()
|
||||
with contextlib.redirect_stdout(out): code = p.main(['--agents', str(path)])
|
||||
self.assertEqual(code, 0)
|
||||
result = json.loads(out.getvalue())
|
||||
self.assertEqual(result['status'], 'source_proposal')
|
||||
for flag in ['identity_evidence_verified', 'custody_admitted', 'live_activated']:
|
||||
self.assertFalse(result[flag])
|
||||
self.assertEqual(len(result['clients']), 2)
|
||||
self.assertEqual(result['identity_references']['playwright-agent-test-agent'],
|
||||
IDENTITY['identity_record'])
|
||||
|
||||
def test_cli_contains_invalid_private_or_duplicate_input(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / 'input.json'
|
||||
for raw in ['synthetic-private-marker', '[{"agent_id":"a","agent_id":"b"}]',
|
||||
json.dumps([dict(IDENTITY, secret='synthetic-private-marker')]), 'x' * 65537]:
|
||||
path.write_text(raw)
|
||||
out = io.StringIO()
|
||||
with contextlib.redirect_stdout(out): code = p.main(['--agents', str(path)])
|
||||
self.assertEqual(code, 1)
|
||||
self.assertNotIn('synthetic-private-marker', out.getvalue())
|
||||
self.assertEqual(json.loads(out.getvalue())['status'], 'refused')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
import base64
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
import yaml
|
||||
|
||||
spec = importlib.util.spec_from_file_location('portal_rollout', Path(__file__).with_name('portal-client-rollout.py'))
|
||||
rollout = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(rollout)
|
||||
|
||||
|
||||
class PortalClientTests(unittest.TestCase):
|
||||
def fixture(self, clients=None):
|
||||
# Synthetic marker values only; no live Secret is loaded by this suite.
|
||||
raw = ('# preserve this comment\nauthelia:\n issuer: https://auth.example.invalid\n'
|
||||
' clientSecret: synthetic-marker\nclients:\n' +
|
||||
yaml.safe_dump(json.loads(json.dumps(clients or [{'clientId': 'existing', 'clientType': 'public'}])), sort_keys=False) +
|
||||
'tokenLifetime: 15m\n')
|
||||
return {'data': {'config.yaml': base64.b64encode(raw.encode()).decode(), 'key.pem': 'synthetic-key'}}, raw
|
||||
|
||||
def test_add_preserves_other_values_and_original_bytes(self):
|
||||
secret, raw = self.fixture()
|
||||
encoded, changed = rollout.replacement(secret)
|
||||
updated = base64.b64decode(encoded).decode()
|
||||
self.assertTrue(changed)
|
||||
self.assertTrue(updated.startswith(raw[:raw.index('tokenLifetime:')]))
|
||||
self.assertTrue(updated.endswith('tokenLifetime: 15m\n'))
|
||||
expected = yaml.safe_load(raw)
|
||||
expected['clients'].append(rollout.portal.CLIENT)
|
||||
self.assertEqual(yaml.safe_load(updated), expected)
|
||||
self.assertEqual(secret['data']['key.pem'], 'synthetic-key')
|
||||
|
||||
def test_legacy_callback_upgrade_preserves_bytes_and_is_idempotent(self):
|
||||
legacy = dict(rollout.portal.CLIENT, redirectUris=[
|
||||
'https://users.92-205-62-239.nip.io/oidc/callback'])
|
||||
secret, raw = self.fixture([{'clientId': 'existing', 'clientType': 'public'}, legacy])
|
||||
encoded, changed = rollout.replacement(secret)
|
||||
updated = base64.b64decode(encoded).decode()
|
||||
self.assertTrue(changed)
|
||||
self.assertEqual(updated.replace(' - https://users.coulomb.social/oidc/callback\n', ''), raw)
|
||||
self.assertEqual(yaml.safe_load(updated)['clients'][-1], rollout.portal.CLIENT)
|
||||
secret['data']['config.yaml'] = encoded
|
||||
self.assertEqual(rollout.replacement(secret), (encoded, False))
|
||||
|
||||
def test_upgrade_refuses_extra_scope_or_unreviewed_callback(self):
|
||||
for extra in [dict(allowedScopes=['openid', 'admin']),
|
||||
dict(redirectUris=['https://users.coulomb.social/oidc/callback']),
|
||||
dict(clientType='confidential')]:
|
||||
with self.subTest(extra=extra), self.assertRaises(rollout.Refused):
|
||||
rollout.replacement(self.fixture([dict(rollout.portal.CLIENT, **extra)])[0])
|
||||
|
||||
def test_exact_existing_registration_is_noop(self):
|
||||
secret, _ = self.fixture([rollout.portal.CLIENT])
|
||||
self.assertEqual(rollout.replacement(secret), (secret['data']['config.yaml'], False))
|
||||
|
||||
def test_differing_or_duplicate_registration_refused(self):
|
||||
for clients in [[dict(rollout.portal.CLIENT, redirectUris=['https://other.invalid/callback'])],
|
||||
[rollout.portal.CLIENT, rollout.portal.CLIENT]]:
|
||||
with self.subTest(clients=len(clients)), self.assertRaises(rollout.Refused):
|
||||
rollout.replacement(self.fixture(clients)[0])
|
||||
|
||||
def test_bootstrap_retains_same_public_registration(self):
|
||||
clients = json.loads(Path(__file__).with_name('bootstrap-config.json').read_text())['clients']
|
||||
self.assertEqual([c for c in clients if c['clientId'] == rollout.portal.CLIENT_ID],
|
||||
[rollout.portal.CLIENT])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
import base64
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
import yaml
|
||||
|
||||
spec = importlib.util.spec_from_file_location('vergabe', Path(__file__).with_name('vergabe-client-rollout.py'))
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
|
||||
class RegistrationTests(unittest.TestCase):
|
||||
def secret(self, clients):
|
||||
self.config = {'issuer': 'https://kc.coulomb.social', 'authelia': {'issuer': 'https://auth.coulomb.social'},
|
||||
'clients': clients}
|
||||
raw = yaml.safe_dump(self.config, sort_keys=False)
|
||||
return {'data': {'config.yaml': base64.b64encode(raw.encode()).decode()}}
|
||||
|
||||
def test_adds_only_exact_public_client_and_is_idempotent(self):
|
||||
prior = {'clientId': 'existing', 'clientType': 'public',
|
||||
'redirectUris': ['https://existing.example/callback']}
|
||||
secret = self.secret([prior])
|
||||
encoded, changed = module.replacement(secret)
|
||||
self.assertTrue(changed)
|
||||
result = yaml.safe_load(base64.b64decode(encoded))
|
||||
self.assertEqual(dict(self.config, clients=[prior, module.CLIENT]), result)
|
||||
secret['data']['config.yaml'] = encoded
|
||||
self.assertEqual((encoded, False), module.replacement(secret))
|
||||
|
||||
def test_no_existing_client_rewrite_and_new_binary_invalid_fields_refused(self):
|
||||
for client in [dict(module.CLIENT, redirectUris=['https://wrong.example/']),
|
||||
{'clientId': 'existing', 'roles': ['operator']}]:
|
||||
with self.assertRaises(module.rollout.Refused):
|
||||
module.replacement(self.secret([client]))
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Fixed Vergabe registration using the existing guarded KeyCape rollout lane."""
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
ROOT = Path(__file__).resolve().parent
|
||||
spec = importlib.util.spec_from_file_location('portal_rollout', ROOT / 'portal-client-rollout.py')
|
||||
rollout = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(rollout)
|
||||
CLIENT = {
|
||||
'clientId': 'vergabe-demo-company',
|
||||
'displayName': 'Vergabe Demo Company',
|
||||
'redirectUris': ['https://vergabe-teilnahme.coulomb.social/demo-company/accounts/oidc/callback/'],
|
||||
'allowedScopes': ['openid', 'profile', 'groups'],
|
||||
'grantTypes': ['authorization_code'],
|
||||
'clientType': 'public',
|
||||
}
|
||||
# Reuse the owner's byte-preserving insertion, CAS, cluster pin and safe receipts.
|
||||
# The portal-specific legacy migration branch is explicitly unreachable here.
|
||||
original_replacement = rollout.replacement
|
||||
rollout.portal = SimpleNamespace(CLIENT_ID=CLIENT['clientId'], CLIENT=CLIENT)
|
||||
|
||||
|
||||
def replacement(secret):
|
||||
_, config, _ = rollout.pin.issuer_document(secret)
|
||||
for client in config.get('clients', []):
|
||||
if 'client_credentials' not in client.get('grantTypes', []):
|
||||
rollout.require(not client.get('roles') and not client.get('serviceSubject'),
|
||||
'browser_service_identity_fields_block_new_binary')
|
||||
if client.get('clientId') == CLIENT['clientId']:
|
||||
rollout.require(client == CLIENT, 'existing_vergabe_registration_differs')
|
||||
return original_replacement(secret)
|
||||
|
||||
|
||||
rollout.replacement = replacement
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(rollout.main())
|
||||
|
|
@ -0,0 +1,103 @@
|
|||
#!/usr/bin/env bash
|
||||
# Verify the live KeyCape config carries the OpenBao admin client and KeyCape
|
||||
# is serving OIDC discovery after rollout.
|
||||
|
||||
set +x
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
|
||||
NAMESPACE="${KEYCAPE_NAMESPACE:-sso}"
|
||||
SECRET="${KEYCAPE_CONFIG_SECRET:-keycape-config}"
|
||||
KUBECTL="${KUBECTL:-kubectl}"
|
||||
PORT="${KEYCAPE_VERIFY_PORT:-18080}"
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
"$KUBECTL" get secret "$SECRET" -n "$NAMESPACE" -o json \
|
||||
| python3 "$SCRIPT_DIR/openbao-client-config.py" verify
|
||||
|
||||
PUBLIC_URL="${KEYCAPE_PUBLIC_URL:-https://kc.coulomb.social}"
|
||||
PUBLIC_AUTHORIZE_URL="${PUBLIC_URL%/}/authorize"
|
||||
|
||||
probe_redirect() {
|
||||
local label="$1"
|
||||
local redirect_uri="$2"
|
||||
local output
|
||||
output=$(
|
||||
curl -sS -i -G "$PUBLIC_AUTHORIZE_URL" \
|
||||
--data-urlencode "client_id=openbao-admin" \
|
||||
--data-urlencode "redirect_uri=$redirect_uri" \
|
||||
--data-urlencode "response_type=code" \
|
||||
--data-urlencode "scope=openid profile email groups" \
|
||||
--data-urlencode "state=netkingdom-openbao-client-probe" \
|
||||
--data-urlencode "code_challenge=abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQ" \
|
||||
--data-urlencode "code_challenge_method=S256" \
|
||||
2>&1 || true
|
||||
)
|
||||
|
||||
if grep -q '"unknown client_id"' <<<"$output"; then
|
||||
echo "[FAIL] $PUBLIC_AUTHORIZE_URL rejects openbao-admin with unknown client_id" >&2
|
||||
echo " Check DNS for kc.coulomb.social and ensure it reaches the KeyCape ingress that was patched." >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -qE '^HTTP/[0-9.]+ 302 ' <<<"$output"; then
|
||||
echo "[FAIL] $PUBLIC_AUTHORIZE_URL did not accept the $label redirect URI for openbao-admin" >&2
|
||||
echo " Redirect URI: $redirect_uri" >&2
|
||||
echo " Provider response withheld; use owner-scoped metadata diagnostics." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "[PASS] public KeyCape authorize endpoint accepts $label redirect"
|
||||
}
|
||||
|
||||
probe_redirect "CLI" "http://localhost:8250/oidc/callback"
|
||||
probe_redirect "operator-tunneled browser UI netkingdom mount" "http://127.0.0.1:18200/ui/vault/auth/netkingdom/oidc/callback"
|
||||
|
||||
KC_POD=$("$KUBECTL" get pod -n "$NAMESPACE" \
|
||||
-l app.kubernetes.io/name=keycape \
|
||||
--field-selector=status.phase=Running \
|
||||
-o jsonpath='{.items[0].metadata.name}' 2>/dev/null || true)
|
||||
|
||||
if [[ -z "$KC_POD" ]]; then
|
||||
echo "[FAIL] no running KeyCape pod found in namespace $NAMESPACE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
PF_LOG=$(mktemp "${TMPDIR:-/tmp}/netkingdom-keycape-verify-XXXXXXXX")
|
||||
|
||||
"$KUBECTL" -n "$NAMESPACE" port-forward --address 127.0.0.1 svc/keycape "$PORT:8080" \
|
||||
>"$PF_LOG" 2>&1 &
|
||||
PF_PID=$!
|
||||
|
||||
cleanup() {
|
||||
kill "$PF_PID" 2>/dev/null || true
|
||||
wait "$PF_PID" 2>/dev/null || true
|
||||
rm -f -- "$PF_LOG"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
for _ in $(seq 1 30); do
|
||||
if python3 - "$PORT" <<'PY' >/dev/null 2>&1
|
||||
import json
|
||||
import sys
|
||||
import urllib.request
|
||||
|
||||
port = sys.argv[1]
|
||||
with urllib.request.urlopen(f"http://127.0.0.1:{port}/.well-known/openid-configuration", timeout=2) as response:
|
||||
payload = json.load(response)
|
||||
if not payload.get("issuer"):
|
||||
raise SystemExit("missing issuer")
|
||||
PY
|
||||
then
|
||||
echo "[PASS] KeyCape discovery endpoint responds via local port-forward to pod $KC_POD"
|
||||
exit 0
|
||||
fi
|
||||
if ! kill -0 "$PF_PID" 2>/dev/null; then
|
||||
echo "[FAIL] KeyCape port-forward exited before discovery responded" >&2
|
||||
echo "Port-forward diagnostics withheld." >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
echo "[FAIL] KeyCape discovery endpoint did not respond via local port-forward" >&2
|
||||
echo "Port-forward diagnostics withheld." >&2
|
||||
exit 1
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Read-only native authorize checks; print no state, cookies or auth tokens."""
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import secrets
|
||||
from urllib.error import HTTPError
|
||||
from urllib.parse import urlencode, urlsplit
|
||||
from urllib.request import HTTPRedirectHandler, build_opener
|
||||
|
||||
|
||||
class NoRedirect(HTTPRedirectHandler):
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||
return None
|
||||
|
||||
|
||||
def verify():
|
||||
opener = build_opener(NoRedirect)
|
||||
base = {
|
||||
"client_id": "user-engine-portal", "response_type": "code",
|
||||
"redirect_uri": "https://users.92-205-62-239.nip.io/oidc/callback",
|
||||
"scope": "openid profile email groups", "state": secrets.token_urlsafe(24),
|
||||
"code_challenge": base64.urlsafe_b64encode(
|
||||
hashlib.sha256(secrets.token_bytes(32)).digest()
|
||||
).rstrip(b"=").decode(), "code_challenge_method": "S256",
|
||||
}
|
||||
results = []
|
||||
for name in ("valid", "wrong_callback", "missing_pkce"):
|
||||
params = dict(base)
|
||||
if name == "wrong_callback":
|
||||
params["redirect_uri"] = "https://invalid.example/oidc/callback"
|
||||
if name == "missing_pkce":
|
||||
params.pop("code_challenge")
|
||||
params.pop("code_challenge_method")
|
||||
try:
|
||||
response = opener.open("https://kc.coulomb.social/authorize?" + urlencode(params), timeout=20)
|
||||
except HTTPError as error:
|
||||
response = error
|
||||
with response:
|
||||
row = {"check": name, "status": response.code}
|
||||
if name == "valid":
|
||||
location = urlsplit(response.headers.get("Location", ""))
|
||||
row["redirect_origin"] = location.scheme + "://" + location.netloc
|
||||
row["passed"] = response.code == 302 and row["redirect_origin"] == "https://auth.coulomb.social"
|
||||
else:
|
||||
body = json.loads(response.read(4096))
|
||||
row.update(error=body.get("error"), feature=body.get("feature"))
|
||||
expected = "redirect_uri" if name == "wrong_callback" else "missing_pkce"
|
||||
row["passed"] = response.code == 400 and row["error"] == "invalid_profile_usage" and row["feature"] == expected
|
||||
results.append(row)
|
||||
print(json.dumps(results, indent=2))
|
||||
return all(row["passed"] for row in results)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(0 if verify() else 1)
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
#!/usr/bin/env bash
|
||||
# Explicit private input and preview/readback adapter; see lldap/GROUP-MAINTENANCE.md.
|
||||
set +x
|
||||
set -euo pipefail
|
||||
exec python3 "$(dirname -- "$0")/lldap_admin.py" bootstrap "$@"
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
#!/usr/bin/env bash
|
||||
# Retired by NK-WP-0049. Refuse before inspecting arguments or credentials.
|
||||
# Keep this entry point so stale callers fail visibly instead of mutating IAM.
|
||||
printf '%s\n' 'This legacy LLDAP lifecycle command is retired.' 'See docs/legacy-lldap-lifecycle-retirement.md for owner routes and recovery limits.' >&2
|
||||
exit 2
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
#!/usr/bin/env bash
|
||||
# Private creation or exact-match verification; rotations require --rotate-key.
|
||||
set +x
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
exec python3 "$SCRIPT_DIR/../../bootstrap/apply-service-secrets.py" lldap "$@"
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
#!/usr/bin/env bash
|
||||
# Retired by NK-WP-0049. Refuse before inspecting arguments or credentials.
|
||||
# Keep this entry point so stale callers fail visibly instead of mutating IAM.
|
||||
printf '%s\n' 'This legacy LLDAP lifecycle command is retired.' 'See docs/legacy-lldap-lifecycle-retirement.md for owner routes and recovery limits.' >&2
|
||||
exit 2
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
#!/usr/bin/env bash
|
||||
# Retired by NK-WP-0049. Refuse before inspecting arguments or credentials.
|
||||
# Keep this entry point so stale callers fail visibly instead of mutating IAM.
|
||||
printf '%s\n' 'This legacy LLDAP lifecycle command is retired.' 'See docs/legacy-lldap-lifecycle-retirement.md for owner routes and recovery limits.' >&2
|
||||
exit 2
|
||||
|
|
@ -0,0 +1,144 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Attended repair of one consumer field using an operator-held current password.
|
||||
|
||||
Never reads a Secret payload, changes a provider password, or copies a bundle.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import getpass
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
CLUSTER_UID = 'a553c742-0115-43d4-99a4-a5ca56fe0786'
|
||||
KUBE = ['kubectl', '--kubeconfig', '/home/worsch/.kube/config-hosteurope',
|
||||
'--server', 'https://127.0.0.1:16444', '--request-timeout=20s']
|
||||
NAMESPACE = 'sso'
|
||||
SECRET = 'lldap-secrets'
|
||||
KEY = 'LLDAP_LDAP_USER_PASS'
|
||||
# The password and returned directory token remain in this child process only.
|
||||
# Child stdin carries the operator input; no credential is a process argument.
|
||||
VERIFY = '''import json,os,sys
|
||||
from urllib.error import HTTPError
|
||||
from provisioner import LLDAPProvisioner
|
||||
try:
|
||||
assert os.environ['LLDAP_URL']=='http://lldap.sso.svc.cluster.local:17170'
|
||||
p=LLDAPProvisioner(base_url=os.environ['LLDAP_URL'],admin_password=json.load(sys.stdin)['password'])
|
||||
token=p._login()
|
||||
p._gql(token,'query { groups { id } }',{})
|
||||
except Exception:
|
||||
print('directory-check-failed');sys.exit(1)
|
||||
print('directory-check-passed')
|
||||
'''
|
||||
VERIFY_RELOADED = '''import os,sys
|
||||
from provisioner import LLDAPProvisioner
|
||||
try:
|
||||
p=LLDAPProvisioner(base_url=os.environ['LLDAP_URL'],admin_password=os.environ['LLDAP_ADMIN_PASSWORD'])
|
||||
token=p._login()
|
||||
p._gql(token,'query { groups { id } }',{})
|
||||
except Exception:
|
||||
print('reloaded-check-failed');sys.exit(1)
|
||||
print('reloaded-check-passed')
|
||||
'''
|
||||
|
||||
|
||||
class Refused(Exception):
|
||||
"""Fixed safe code only; never carry child output or an exception repr."""
|
||||
|
||||
|
||||
def run(arguments, *, data=None, timeout=30):
|
||||
try:
|
||||
result = subprocess.run(KUBE + arguments, input=data, text=True,
|
||||
capture_output=True, timeout=timeout)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
raise Refused('command_unavailable') from None
|
||||
if result.returncode:
|
||||
raise Refused('command_refused') from None
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def inspect():
|
||||
uid = run(['get', 'namespace', 'kube-system', '-o', 'jsonpath={.metadata.uid}'])
|
||||
if uid != CLUSTER_UID:
|
||||
raise Refused('wrong_cluster')
|
||||
# Do not include annotations: last-applied configuration can contain data.
|
||||
fields = run(['-n', NAMESPACE, 'get', 'secret', SECRET, '-o',
|
||||
'go-template={{.metadata.uid}} {{.metadata.resourceVersion}} {{if .metadata.ownerReferences}}{{len .metadata.ownerReferences}}{{else}}0{{end}}']).split()
|
||||
if len(fields) != 3 or not re.fullmatch(r'[a-f0-9-]{36}', fields[0]) or not fields[1].isdigit():
|
||||
raise Refused('invalid_metadata')
|
||||
if fields[2] != '0':
|
||||
raise Refused('controller_owned_secret')
|
||||
return {'uid': fields[0], 'resource_version': fields[1]}
|
||||
|
||||
|
||||
def patch(password, metadata):
|
||||
return [
|
||||
{'op': 'test', 'path': '/metadata/uid', 'value': metadata['uid']},
|
||||
{'op': 'test', 'path': '/metadata/resourceVersion', 'value': metadata['resource_version']},
|
||||
{'op': 'replace', 'path': '/data/' + KEY,
|
||||
'value': base64.b64encode(password.encode()).decode()},
|
||||
]
|
||||
|
||||
|
||||
def reconcile(password, *, mode, expected_uid=None, expected_resource_version=None):
|
||||
if not password or len(password) > 4096 or '\x00' in password:
|
||||
raise Refused('invalid_protected_input')
|
||||
metadata = inspect()
|
||||
if mode == 'apply' and metadata != {'uid': expected_uid, 'resource_version': expected_resource_version}:
|
||||
raise Refused('stale_metadata')
|
||||
result = run(['-n', NAMESPACE, 'exec', '-i', 'deployment/identity-provisioner',
|
||||
'--', 'python', '-c', VERIFY], data=json.dumps({'password': password}))
|
||||
if result != 'directory-check-passed':
|
||||
raise Refused('current_provider_credential_unverified')
|
||||
if mode == 'check':
|
||||
return {'result': 'checked', 'provider_login': True, 'secret_written': False, **metadata}
|
||||
payload = json.dumps(patch(password, metadata))
|
||||
command = ['-n', NAMESPACE, 'patch', 'secret', SECRET, '--type=json',
|
||||
'--patch-file=/dev/stdin', '-o', 'name']
|
||||
run(command + ['--dry-run=server'], data=payload)
|
||||
run(command, data=payload)
|
||||
# Do not restore the known nonworking field on failure. Report partial repair.
|
||||
try:
|
||||
run(['-n', NAMESPACE, 'rollout', 'restart', 'deployment/identity-provisioner'])
|
||||
run(['-n', NAMESPACE, 'rollout', 'status', 'deployment/identity-provisioner',
|
||||
'--timeout=120s'], timeout=150)
|
||||
result = run(['-n', NAMESPACE, 'exec', 'deployment/identity-provisioner',
|
||||
'--', 'python', '-c', VERIFY_RELOADED])
|
||||
if result != 'reloaded-check-passed':
|
||||
raise Refused('consumer_verification_failed')
|
||||
except Refused:
|
||||
raise Refused('field_updated_consumer_verification_incomplete') from None
|
||||
return {'result': 'reconciled', 'provider_login': True, 'consumer_login': True,
|
||||
'provider_password_changed': False, 'secret_field': KEY, **inspect()}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('mode', choices=['inspect', 'check', 'apply'])
|
||||
parser.add_argument('--expected-uid')
|
||||
parser.add_argument('--expected-resource-version')
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
if args.mode == 'inspect':
|
||||
result = {'result': 'inspected', **inspect()}
|
||||
else:
|
||||
if args.mode == 'apply' and not (args.expected_uid and args.expected_resource_version):
|
||||
raise Refused('exact_metadata_required')
|
||||
if not sys.stdin.isatty():
|
||||
raise Refused('attended_terminal_required')
|
||||
password = getpass.getpass('Current working LLDAP admin password (hidden): ')
|
||||
result = reconcile(password, mode=args.mode, expected_uid=args.expected_uid,
|
||||
expected_resource_version=args.expected_resource_version)
|
||||
del password
|
||||
print(json.dumps(result, sort_keys=True))
|
||||
return 0
|
||||
except Refused as error:
|
||||
print(json.dumps({'result': 'refused', 'code': str(error)}))
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
|
|
@ -0,0 +1,281 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Owner-invoked LDAP group maintenance; no credential discovery or authority grant."""
|
||||
import argparse
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import ssl
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
sys.path.insert(0, str(ROOT / 'identity-provisioner'))
|
||||
from provisioner import LLDAPProvisioner, _service_group_request # noqa: E402
|
||||
|
||||
BASELINE = ('net-kingdom-users', 'net-kingdom-admins', 'activity-core-operators')
|
||||
MAX_BODY = 1024 * 1024
|
||||
|
||||
|
||||
class Refused(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class Parser(argparse.ArgumentParser):
|
||||
def error(self, message):
|
||||
raise Refused()
|
||||
|
||||
|
||||
def unique_object(pairs):
|
||||
result = {}
|
||||
for key, value in pairs:
|
||||
if key in result:
|
||||
raise Refused()
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
|
||||
def endpoint(value):
|
||||
if not isinstance(value, str) or not value.isascii() or any(ord(c) <= 32 for c in value):
|
||||
raise Refused()
|
||||
parsed = urllib.parse.urlsplit(value)
|
||||
if parsed.username is not None or parsed.password is not None or parsed.query or parsed.fragment or parsed.path not in ('', '/'):
|
||||
raise Refused()
|
||||
if value.rstrip('/') in ('https://lldap.coulomb.social', 'https://lldap.coulomb.social:443'):
|
||||
return value.rstrip('/')
|
||||
if parsed.hostname not in ('127.0.0.1', '::1') or parsed.scheme not in ('http', 'https') or parsed.port is None:
|
||||
raise Refused()
|
||||
if not 1 <= parsed.port <= 65535:
|
||||
raise Refused()
|
||||
expected = f'{parsed.scheme}://{("[::1]" if parsed.hostname == "::1" else parsed.hostname)}:{parsed.port}'
|
||||
if value.rstrip('/') != expected:
|
||||
raise Refused()
|
||||
return expected
|
||||
|
||||
|
||||
def private_password(root):
|
||||
root = Path(os.path.abspath(root))
|
||||
if any((p / '.git').is_file() or (p / '.git/HEAD').is_file() for p in (root, *root.parents)):
|
||||
raise Refused()
|
||||
spec = importlib.util.spec_from_file_location('lldap_custody', ROOT / 'sso-mfa/bootstrap/apply-service-secrets.py')
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
with module.custody(root, ['lldap']) as directories:
|
||||
value = module.read_env(directories['lldap']).get('LLDAP_LDAP_USER_PASS')
|
||||
if not isinstance(value, str) or not value or len(value.encode()) > 4096 or any(ord(c) < 32 for c in value):
|
||||
raise Refused()
|
||||
return value
|
||||
|
||||
|
||||
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||
def redirect_request(self, *args, **kwargs):
|
||||
return None
|
||||
|
||||
|
||||
class Transport:
|
||||
"""Inject bounded, origin-checked transport into the existing provider."""
|
||||
def __init__(self, base):
|
||||
self.base = endpoint(base)
|
||||
context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
||||
context.load_verify_locations(cafile=ssl.get_default_verify_paths().openssl_cafile)
|
||||
self.opener = urllib.request.build_opener(
|
||||
urllib.request.ProxyHandler({}), NoRedirect(), urllib.request.HTTPSHandler(context=context),
|
||||
)
|
||||
|
||||
def __call__(self, request, timeout):
|
||||
if request.full_url not in {self.base + '/auth/simple/login', self.base + '/api/graphql'}:
|
||||
raise Refused()
|
||||
if request.method != 'POST' or not isinstance(request.data, bytes) or len(request.data) > MAX_BODY:
|
||||
raise Refused()
|
||||
try:
|
||||
with self.opener.open(request, timeout=min(timeout, 15)) as response:
|
||||
if response.status != 200:
|
||||
raise Refused()
|
||||
raw = response.read(MAX_BODY + 1)
|
||||
if len(raw) > MAX_BODY:
|
||||
raise Refused()
|
||||
body = json.loads(raw, object_pairs_hook=unique_object)
|
||||
if not isinstance(body, dict):
|
||||
raise Refused()
|
||||
if request.full_url.endswith('/auth/simple/login'):
|
||||
token = body.get('token')
|
||||
if not isinstance(token, str) or not 1 <= len(token) <= 16384 or any(not 33 <= ord(c) <= 126 for c in token):
|
||||
raise Refused()
|
||||
return io.BytesIO(raw)
|
||||
except urllib.error.HTTPError as exc:
|
||||
exc.close()
|
||||
raise Refused() from None
|
||||
except Exception:
|
||||
raise Refused() from None
|
||||
|
||||
|
||||
def snapshot(provider, token):
|
||||
value = provider._gql(token, 'query { users { id } groups { id displayName users { id } } }', {})
|
||||
if set(value) != {'users', 'groups'} or not isinstance(value['users'], list) or not isinstance(value['groups'], list):
|
||||
raise Refused()
|
||||
if len(value['users']) > 10000 or len(value['groups']) > 1000:
|
||||
raise Refused()
|
||||
users = []
|
||||
for user in value['users']:
|
||||
uid = user.get('id') if isinstance(user, dict) else None
|
||||
if not isinstance(uid, str) or not re.fullmatch(r'[a-z][a-z0-9._-]{0,127}', uid):
|
||||
raise Refused()
|
||||
users.append(uid)
|
||||
if len(set(users)) != len(users):
|
||||
raise Refused()
|
||||
groups = {}
|
||||
ids = set()
|
||||
for group in value['groups']:
|
||||
if not isinstance(group, dict):
|
||||
raise Refused()
|
||||
name, gid, members = group.get('displayName'), group.get('id'), group.get('users')
|
||||
if (not isinstance(name, str) or not name or len(name) > 256 or any(ord(c) < 32 for c in name)
|
||||
or type(gid) is not int or gid < 1 or not isinstance(members, list)
|
||||
or name in groups or gid in ids or len(members) > 10000):
|
||||
raise Refused()
|
||||
member_ids = [member.get('id') if isinstance(member, dict) else None for member in members]
|
||||
if any(not isinstance(uid, str) or uid not in users for uid in member_ids) or len(set(member_ids)) != len(member_ids):
|
||||
raise Refused()
|
||||
groups[name] = {'id': gid, 'members': sorted(member_ids)}
|
||||
ids.add(gid)
|
||||
return {'users': sorted(users), 'groups': groups}
|
||||
|
||||
|
||||
def validate_operation(operation, action=None, group=None, user=None):
|
||||
if operation not in {'bootstrap', 'membership', 'verify', 'groups'}:
|
||||
raise Refused()
|
||||
if operation == 'groups':
|
||||
# Read-only sign-in diagnosis preview (docs/sign-in-diagnosis.md).
|
||||
if action is not None or group is not None or not isinstance(user, str) or not re.fullmatch(r'[a-z][a-z0-9._-]{0,127}', user):
|
||||
raise Refused()
|
||||
return
|
||||
if operation != 'membership':
|
||||
if any(v is not None for v in (action, group, user)):
|
||||
raise Refused()
|
||||
return
|
||||
if action not in {'add', 'remove', 'list'} or not isinstance(group, str):
|
||||
raise Refused()
|
||||
if action == 'list':
|
||||
if user is not None:
|
||||
raise Refused()
|
||||
members = {}
|
||||
else:
|
||||
members = {group: [user]}
|
||||
_service_group_request({'groups': [group], 'members': members,
|
||||
'idempotency_key': 'owner-group-maintenance', 'correlation_id': 'owner-group-maintenance'})
|
||||
|
||||
|
||||
def maintain(provider, base, operation, *, action=None, group=None, user=None, apply=False, expected_digest=None):
|
||||
validate_operation(operation, action, group, user)
|
||||
if (apply and (operation in ('verify', 'groups') or action == 'list')) or (expected_digest is not None and not apply):
|
||||
raise Refused()
|
||||
if apply and (not isinstance(expected_digest, str) or not re.fullmatch(r'[0-9a-f]{64}', expected_digest)):
|
||||
raise Refused()
|
||||
token = provider._login()
|
||||
before = snapshot(provider, token)
|
||||
if operation == 'verify':
|
||||
present = {name: name in before['groups'] for name in BASELINE}
|
||||
return {'result': 'structure_observed', 'baseline_groups_present': present,
|
||||
'directory_user_count': len(before['users']),
|
||||
'break_glass_account_present': 'break-glass' in before['users'],
|
||||
'break_glass_platform_membership_present': 'break-glass' in before['groups'].get('net-kingdom-admins', {}).get('members', []),
|
||||
'native_acceptance': 'unverified',
|
||||
'remaining': ['governed_lifecycle', 'actual_login_and_mfa', 'issued_claims_and_consumer_access',
|
||||
'session_revocation', 'independent_recovery']}
|
||||
if operation == 'groups':
|
||||
if user not in before['users']:
|
||||
raise Refused()
|
||||
names = sorted(name for name, item in before['groups'].items() if user in item['members'])
|
||||
return {'result': 'groups_observed', 'uid': user, 'groups': names, 'native_acceptance': 'unverified'}
|
||||
if operation == 'membership' and (group not in before['groups'] or (user is not None and user not in before['users'])):
|
||||
raise Refused()
|
||||
binding = {'base': endpoint(base), 'operation': operation, 'action': action, 'group': group, 'user': user, 'before': before}
|
||||
digest = hashlib.sha256(json.dumps(binding, sort_keys=True, separators=(',', ':')).encode()).hexdigest()
|
||||
if operation == 'bootstrap':
|
||||
changes = [{'create_group': name} for name in BASELINE if name not in before['groups']]
|
||||
elif action == 'list':
|
||||
return {'result': 'membership_observed', 'group': group, 'members': before['groups'][group]['members'], 'native_acceptance': 'unverified'}
|
||||
else:
|
||||
member = user in before['groups'][group]['members']
|
||||
changes = [{'action': action, 'group': group, 'user': user}] if member != (action == 'add') else []
|
||||
result = {'operation': operation, 'changes': changes, 'snapshot_sha256': digest, 'native_acceptance': 'unverified'}
|
||||
if not apply:
|
||||
return dict(result, result='preview')
|
||||
if digest != expected_digest:
|
||||
raise Refused()
|
||||
# LLDAP has no compare-and-swap transaction here. Caller must hold the
|
||||
# owner-controlled exclusive window; check again before the first mutation.
|
||||
if snapshot(provider, token) != before:
|
||||
raise Refused()
|
||||
if operation == 'bootstrap':
|
||||
rows = [{'displayName': name, 'id': item['id']} for name, item in before['groups'].items()]
|
||||
for change in changes:
|
||||
provider._ensure_group(token, rows, change['create_group'])
|
||||
elif changes:
|
||||
method = provider._add_group if action == 'add' else provider._remove_group
|
||||
method(token, user, before['groups'][group]['id'])
|
||||
after = snapshot(provider, token)
|
||||
expected = json.loads(json.dumps(before))
|
||||
if operation == 'bootstrap':
|
||||
for change in changes:
|
||||
name = change['create_group']
|
||||
if name not in after['groups'] or after['groups'][name]['members']:
|
||||
raise Refused()
|
||||
expected['groups'][name] = after['groups'][name]
|
||||
elif changes:
|
||||
members = set(expected['groups'][group]['members'])
|
||||
if action == 'add':
|
||||
members.add(user)
|
||||
else:
|
||||
members.discard(user)
|
||||
expected['groups'][group]['members'] = sorted(members)
|
||||
if after != expected:
|
||||
raise Refused()
|
||||
return dict(result, result='verified', recovery='no_automatic_rollback')
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
phase = 'input'
|
||||
try:
|
||||
parser = Parser(description=__doc__)
|
||||
sub = parser.add_subparsers(dest='operation', required=True, parser_class=Parser)
|
||||
for operation in ('bootstrap', 'membership', 'verify', 'groups'):
|
||||
command = sub.add_parser(operation)
|
||||
command.add_argument('--root', required=True, help='Explicit private custody directory outside Git')
|
||||
command.add_argument('--lldap-url', default='https://lldap.coulomb.social')
|
||||
if operation == 'groups':
|
||||
command.add_argument('--user', required=True)
|
||||
elif operation != 'verify':
|
||||
command.add_argument('--apply', action='store_true')
|
||||
command.add_argument('--expected-digest')
|
||||
if operation == 'membership':
|
||||
command.add_argument('action', choices=('add', 'remove', 'list'))
|
||||
command.add_argument('--group', required=True)
|
||||
command.add_argument('--user')
|
||||
args = parser.parse_args(argv)
|
||||
base = endpoint(args.lldap_url)
|
||||
action, group, user = (getattr(args, name, None) for name in ('action', 'group', 'user'))
|
||||
validate_operation(args.operation, action, group, user)
|
||||
apply = getattr(args, 'apply', False)
|
||||
expected = getattr(args, 'expected_digest', None)
|
||||
if (expected is not None and not apply) or (apply and (action == 'list' or not isinstance(expected, str) or not re.fullmatch(r'[0-9a-f]{64}', expected))):
|
||||
raise Refused()
|
||||
transport = Transport(base)
|
||||
password = private_password(args.root)
|
||||
provider = LLDAPProvisioner(base_url=base, admin_password=password, opener=transport)
|
||||
phase = 'maintenance' if apply else 'observation'
|
||||
result = maintain(provider, base, args.operation, action=action, group=group, user=user, apply=apply, expected_digest=expected)
|
||||
print(json.dumps(result, sort_keys=True))
|
||||
return 2 if args.operation == 'verify' else 0
|
||||
except Exception:
|
||||
print(json.dumps({'result': 'unverified', 'phase': phase, 'recovery': 'reconcile_current_state_before_retry'}), file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
#!/usr/bin/env bash
|
||||
# Explicit private input and preview/readback adapter; see lldap/GROUP-MAINTENANCE.md.
|
||||
set +x
|
||||
set -euo pipefail
|
||||
exec python3 "$(dirname -- "$0")/lldap_admin.py" membership "$@"
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
import base64
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
spec=importlib.util.spec_from_file_location('repair',Path(__file__).with_name('identity-provisioner-reconcile.py'))
|
||||
repair=importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(repair)
|
||||
META={'uid':'c6a9e6be-5bb5-47e6-9faa-06b8d72afec3','resource_version':'1234'}
|
||||
SYNTHETIC='synthetic-only-current-password'
|
||||
|
||||
|
||||
class ReconcileTests(unittest.TestCase):
|
||||
def test_patch_has_exact_guards_and_changes_only_the_named_field(self):
|
||||
changes=repair.patch(SYNTHETIC,META)
|
||||
self.assertEqual(['test','test','replace'],[x['op'] for x in changes])
|
||||
self.assertEqual('/metadata/uid',changes[0]['path'])
|
||||
self.assertEqual('/metadata/resourceVersion',changes[1]['path'])
|
||||
self.assertEqual('/data/LLDAP_LDAP_USER_PASS',changes[2]['path'])
|
||||
self.assertEqual(SYNTHETIC,base64.b64decode(changes[2]['value']).decode())
|
||||
|
||||
def test_stale_metadata_stops_before_credential_use(self):
|
||||
with patch.object(repair,'inspect',return_value=META), patch.object(repair,'run') as run:
|
||||
with self.assertRaisesRegex(repair.Refused,'stale_metadata'):
|
||||
repair.reconcile(SYNTHETIC,mode='apply',expected_uid=META['uid'],expected_resource_version='old')
|
||||
run.assert_not_called()
|
||||
|
||||
def test_provider_refusal_stops_before_any_write(self):
|
||||
with patch.object(repair,'inspect',return_value=META), patch.object(repair,'run',side_effect=repair.Refused('command_refused')) as run:
|
||||
with self.assertRaises(repair.Refused):
|
||||
repair.reconcile(SYNTHETIC,mode='apply',expected_uid=META['uid'],expected_resource_version=META['resource_version'])
|
||||
self.assertEqual(1,run.call_count)
|
||||
self.assertNotIn('patch',run.call_args.args[0])
|
||||
|
||||
def test_check_does_not_write_and_keeps_password_out_of_arguments(self):
|
||||
with patch.object(repair,'inspect',return_value=META), patch.object(repair,'run',return_value='directory-check-passed') as run:
|
||||
result=repair.reconcile(SYNTHETIC,mode='check')
|
||||
self.assertFalse(result['secret_written'])
|
||||
self.assertNotIn(SYNTHETIC,str(run.call_args.args))
|
||||
self.assertNotIn(SYNTHETIC,json.dumps(result))
|
||||
self.assertEqual(SYNTHETIC,json.loads(run.call_args.kwargs['data'])['password'])
|
||||
|
||||
def test_apply_dry_runs_then_writes_reloads_and_verifies(self):
|
||||
results=['directory-check-passed','secret/lldap-secrets','secret/lldap-secrets','restarted','ready','reloaded-check-passed']
|
||||
with patch.object(repair,'inspect',return_value=META), patch.object(repair,'run',side_effect=results) as run:
|
||||
result=repair.reconcile(SYNTHETIC,mode='apply',expected_uid=META['uid'],expected_resource_version=META['resource_version'])
|
||||
self.assertEqual('reconciled',result['result'])
|
||||
self.assertFalse(result['provider_password_changed'])
|
||||
calls=run.call_args_list
|
||||
self.assertIn('--dry-run=server',calls[1].args[0])
|
||||
self.assertNotIn('--dry-run=server',calls[2].args[0])
|
||||
for call in calls:
|
||||
self.assertNotIn(SYNTHETIC,str(call.args))
|
||||
self.assertNotIn(base64.b64encode(SYNTHETIC.encode()).decode(),str(call.args))
|
||||
|
||||
def test_child_errors_never_reveal_output(self):
|
||||
failed=subprocess.CompletedProcess([],1,stdout=SYNTHETIC,stderr=SYNTHETIC)
|
||||
with patch.object(subprocess,'run',return_value=failed):
|
||||
with self.assertRaises(repair.Refused) as result:
|
||||
repair.run(['anything'])
|
||||
self.assertEqual('command_refused',str(result.exception))
|
||||
|
||||
def test_wrong_cluster_and_controller_owner_refused(self):
|
||||
with patch.object(repair,'run',return_value='foreign-cluster'):
|
||||
with self.assertRaisesRegex(repair.Refused,'wrong_cluster'): repair.inspect()
|
||||
with patch.object(repair,'run',side_effect=[repair.CLUSTER_UID,META['uid']+' 1234 1']):
|
||||
with self.assertRaisesRegex(repair.Refused,'controller_owned_secret'): repair.inspect()
|
||||
|
||||
|
||||
if __name__=='__main__': unittest.main()
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
#!/usr/bin/env bash
|
||||
# Private creation or exact-match verification; rotations require --rotate-key.
|
||||
set +x
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
exec python3 "$SCRIPT_DIR/../../bootstrap/apply-service-secrets.py" postgresql "$@"
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
"""Provider-local activation after the guarded deployment; never changes tokens."""
|
||||
import contextlib,io,json,logging
|
||||
result={'success':False}
|
||||
with contextlib.redirect_stdout(io.StringIO()),contextlib.redirect_stderr(io.StringIO()):
|
||||
try:
|
||||
logging.disable(logging.CRITICAL)
|
||||
from privacyidea.app import create_app
|
||||
from privacyidea.lib.policy import PolicyClass,set_policy
|
||||
app=create_app(config_name='production',silent=True)
|
||||
with app.app_context():
|
||||
if app.config.get('PI_INIT_CHECK_HOOK')!='keycape_onboarding_guard.check':raise ValueError('hook_not_active')
|
||||
from keycape_onboarding_guard import check
|
||||
rows=[r for r in PolicyClass().policies if r['scope']=='user' and r['active']]
|
||||
expected=next(r for r in rows if r['name']=='totp-self-enrollment')
|
||||
if expected['realm']!=['coulomb'] or expected.get('conditions') or expected['action'] not in [{'enrollTOTP':True,'delete':True,'disable':True},{'enrollTOTP':True}]:raise ValueError('self_service_policy_changed')
|
||||
if any(r['name'] not in {'totp-self-enrollment','keycape-pending-enrollment-cancel'} and (not r['realm'] or 'coulomb' in r['realm']) for r in rows):raise ValueError('additional_user_policy_requires_review')
|
||||
set_policy(name='totp-self-enrollment',scope='user',action='enrollTOTP',realm='coulomb')
|
||||
set_policy(name='keycape-pending-enrollment-cancel',scope='user',action='delete',realm='coulomb',conditions=[('token','rollout_state','equals','verify',True)])
|
||||
fresh=PolicyClass().policies
|
||||
active=next(r for r in fresh if r['name']=='totp-self-enrollment')
|
||||
pending=next(r for r in fresh if r['name']=='keycape-pending-enrollment-cancel')
|
||||
if active['action']!={'enrollTOTP':True} or not pending['conditions']:raise ValueError('readback_failed')
|
||||
result={'success':True,'hook':'keycape_onboarding_guard.check','self_service_enrollment':True,'pending_cancel_only':True,'active_factor_changes_require_recovery':True,'existing_tokens_changed':False}
|
||||
except Exception as error:result={'success':False,'failure_type':type(error).__name__}
|
||||
print(json.dumps(result));raise SystemExit(0 if result['success'] else 1)
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
"""Version-scoped in-pod bootstrap. Password input is JSON on stdin only.
|
||||
|
||||
Existing credentials/policies are never updated. Insert-only DB writes and
|
||||
unique constraints protect against concurrent creation. Native MFA/effective
|
||||
policy acceptance is a separate attended procedure.
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import sys
|
||||
|
||||
NAME = 'pi-admin'
|
||||
POLICY = 'pi-admin-all-rights'
|
||||
|
||||
|
||||
class Refused(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def admitted_policy(policy):
|
||||
if (policy.get('name') != POLICY or policy.get('scope') != 'admin'
|
||||
or policy.get('adminuser') != [NAME] or policy.get('action') != {'*': True}
|
||||
or policy.get('active') is not True or policy.get('priority') != 1
|
||||
or policy.get('check_all_resolvers') is not False
|
||||
or policy.get('user_case_insensitive') is not False):
|
||||
return False
|
||||
return all(not policy.get(key) for key in (
|
||||
'realm', 'adminrealm', 'resolver', 'pinode', 'user', 'client', 'time', 'conditions', 'user_agents'))
|
||||
|
||||
|
||||
def ensure(password, admins, policies, verify, insert):
|
||||
existing = NAME in admins
|
||||
named = [policy for policy in policies if policy.get('name') == POLICY]
|
||||
if len(named) > 1 or (named and not admitted_policy(named[0])): raise Refused()
|
||||
if not named:
|
||||
# Introducing the first admin policy changes defaults for every admin.
|
||||
# Existing deployments need their owner's policy migration, not bootstrap.
|
||||
if any(admin != NAME for admin in admins) or any(p.get('scope') == 'admin' for p in policies):
|
||||
raise Refused()
|
||||
if existing and not verify(NAME, password): raise Refused()
|
||||
if not existing or not named:
|
||||
insert(password, account=not existing, policy=not named)
|
||||
if not verify(NAME, password): raise Refused()
|
||||
return {'result': 'verified' if existing else 'created', 'mfa_verified': False}
|
||||
|
||||
|
||||
def main():
|
||||
from importlib.metadata import version
|
||||
if version('privacyIDEA') != '3.12.2': raise Refused()
|
||||
raw = sys.stdin.buffer.read(1025)
|
||||
if len(raw) > 1024: raise Refused()
|
||||
payload = json.loads(raw)
|
||||
if (not isinstance(payload, dict) or set(payload) != {'password'}
|
||||
or not isinstance(payload['password'], str)
|
||||
or not re.fullmatch(r'[A-Za-z0-9]{32,128}', payload['password'])):
|
||||
raise Refused()
|
||||
# Avoid library diagnostics carrying credential/configuration data.
|
||||
logging.disable(logging.CRITICAL)
|
||||
from privacyidea.cli import create_silent_app
|
||||
from privacyidea.lib.auth import list_db_admin, verify_db_admin
|
||||
from privacyidea.lib.crypto import hash_with_pepper
|
||||
from privacyidea.lib.policy import get_policies
|
||||
from privacyidea.models import Admin, Policy, db
|
||||
from privacyidea.models.config import save_config_timestamp
|
||||
app = create_silent_app()
|
||||
with app.app_context():
|
||||
# Username-only bootstrap policy must not admit namesakes in other realms.
|
||||
if app.config.get('SUPERUSER_REALM', []): raise Refused()
|
||||
def insert(password, *, account, policy):
|
||||
try:
|
||||
# Do not call Admin.save/create_db_admin or set_policy: they upsert.
|
||||
if account:
|
||||
db.session.add(Admin(username=NAME, password=hash_with_pepper(password)))
|
||||
if policy:
|
||||
db.session.add(Policy(name=POLICY, scope='admin', action='*', adminuser=NAME))
|
||||
db.session.flush() # constraint conflicts fail without updates
|
||||
if policy: save_config_timestamp()
|
||||
db.session.commit()
|
||||
except Exception:
|
||||
db.session.rollback()
|
||||
raise Refused() from None
|
||||
result = ensure(payload['password'], [admin.username for admin in list_db_admin()],
|
||||
get_policies(), verify_db_admin, insert)
|
||||
if not any(admitted_policy(p) for p in get_policies()): raise Refused()
|
||||
return result
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
# Only the fixed receipt may reach the caller; suppress all library output.
|
||||
import os
|
||||
receipt_fd = os.dup(1)
|
||||
with open(os.devnull, 'w') as sink:
|
||||
os.dup2(sink.fileno(), 1); os.dup2(sink.fileno(), 2)
|
||||
try:
|
||||
result = main()
|
||||
os.write(receipt_fd, (json.dumps(result) + '\n').encode())
|
||||
except Exception:
|
||||
sys.exit(1)
|
||||
finally:
|
||||
os.close(receipt_fd)
|
||||
Loading…
Add table
Add a link
Reference in a new issue