Prepare the secrets-engine-openbao KeyCape client registration as source only
Owner-specific exact-match merge and rollback for sso/keycape-config, the one proposed env secretKeyRef, synthetic tests and NK-WP-0046 for KEY-WP-0037-T03. Nothing is applied; the live rollout waits on decision caafe4be. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Assistant: claude-code Assistant-Model: opus Assistant-Process: 132360@bnt-lap001 Assistant-Session: 769b1afd-4330-4c86-96b2-e510e26db42b
This commit is contained in:
parent
45a189a391
commit
a1e7302930
5 changed files with 549 additions and 0 deletions
|
|
@ -218,6 +218,29 @@ The local callback page exchanges the authorization code and displays only
|
|||
non-secret claims. KeyCape presents a browser OTP challenge between Authelia
|
||||
password login and the final OIDC redirect whenever privacyIDEA requires MFA.
|
||||
|
||||
### Prepared, not live: `secrets-engine-openbao` service client (NK-WP-0046)
|
||||
|
||||
Source only for KeyCape KEY-WP-0037-T03; nothing here is registered or deployed.
|
||||
The live run needs the founder's go (decision `caafe4be-4f68-47b4-9567-316d47b94992`)
|
||||
and the platform-owned Secret `keycape-secrets-engine-openbao-client`
|
||||
(key `client-secret`), which does not exist yet.
|
||||
|
||||
- `secrets-engine-openbao-client-rollout.py` — exact-match merge into
|
||||
`sso/keycape-config`: absent adds only this client, identical is a noop,
|
||||
duplicates/drift/shared `secretRef` refuse. `--operation remove` is the
|
||||
rollback and removes only this exact registration. Default mode is
|
||||
`inspect`; `apply` refuses without `--live-go-decision caafe4be-…` and,
|
||||
for `add`, until the Deployment already carries the env binding.
|
||||
- `secrets-engine-openbao-client.proposed.patch.yaml` — the single env
|
||||
`secretKeyRef` for `KEYCAPE_SECRETS_ENGINE_OPENBAO_CLIENT_SECRET`.
|
||||
- `test_secrets_engine_openbao_client_rollout.py` — synthetic fixtures only.
|
||||
|
||||
Order under the go: Secret materialised → server dry-run of both → env patch
|
||||
(pod restarts with an unused env) → config `apply` → restart → native issuance
|
||||
and denial checks (KEY-WP-0037-T04). Rollback reverses it: `--operation remove`,
|
||||
restart, then drop the env binding. The image pin for that rollout is not yet
|
||||
chosen; see NK-WP-0046.
|
||||
|
||||
## Secrets managed
|
||||
|
||||
| Secret name | Keys | Purpose |
|
||||
|
|
|
|||
222
sso-mfa/k8s/keycape/secrets-engine-openbao-client-rollout.py
Normal file
222
sso-mfa/k8s/keycape/secrets-engine-openbao-client-rollout.py
Normal file
|
|
@ -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,17 @@
|
|||
# PROPOSED, NOT APPLIED. KEY-WP-0037-T03 / NK-WP-0046.
|
||||
# Adds exactly one env binding to sso/keycape; values never appear here.
|
||||
# Prerequisites: platform has materialised Secret keycape-secrets-engine-openbao-client
|
||||
# (key client-secret) and the founder's live go (decision caafe4be) is resolved.
|
||||
# Apply as a strategic-merge patch (env merges by name), server dry-run and diff
|
||||
# first; then fold into deployment.yaml in the same change that records the go.
|
||||
spec:
|
||||
template:
|
||||
spec:
|
||||
containers:
|
||||
- name: keycape
|
||||
env:
|
||||
- name: KEYCAPE_SECRETS_ENGINE_OPENBAO_CLIENT_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: keycape-secrets-engine-openbao-client
|
||||
key: client-secret
|
||||
|
|
@ -0,0 +1,211 @@
|
|||
import base64
|
||||
import contextlib
|
||||
import copy
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
import yaml
|
||||
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
'se_openbao_rollout', Path(__file__).with_name('secrets-engine-openbao-client-rollout.py'))
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
UID = '00000000-0000-4000-8000-000000000001'
|
||||
KEYCAPE_EXAMPLE = Path('/home/worsch/key-cape/config/service-clients.example.yaml')
|
||||
EXISTING = [
|
||||
{'clientId': 'openbao-admin', 'clientType': 'public',
|
||||
'redirectUris': ['http://localhost:8250/oidc/callback']},
|
||||
{'clientId': 'secrets-engine-approval', 'audience': 'approval-engine',
|
||||
'grantTypes': ['client_credentials'], 'clientType': 'confidential',
|
||||
'secretRef': 'env:KEYCAPE_SECRETS_ENGINE_APPROVAL_CLIENT_SECRET',
|
||||
'serviceSubject': 'service:secrets-engine', 'tenant': 'tenant:platform',
|
||||
'roles': ['secrets-engine'], 'tokenLifetime': '15m'},
|
||||
]
|
||||
|
||||
|
||||
def fixture(clients=None, tail='tokenLifetime: 15m\npolicyPath: /var/lib/keycape-policy/policy.yaml\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-marker\n'
|
||||
'clients:\n' + yaml.safe_dump(json.loads(json.dumps(EXISTING if clients is None else clients)),
|
||||
sort_keys=False) + tail)
|
||||
secret = {'metadata': {'uid': UID, 'resourceVersion': '7'},
|
||||
'data': {'config.yaml': base64.b64encode(raw.encode()).decode(),
|
||||
'signing-key.pem': 'c3ludGhldGljLXNpZ25pbmcta2V5'}}
|
||||
return secret, raw
|
||||
|
||||
|
||||
def decode(encoded):
|
||||
return base64.b64decode(encoded).decode()
|
||||
|
||||
|
||||
class RegistrationTests(unittest.TestCase):
|
||||
def test_add_appends_only_this_client_and_preserves_bytes(self):
|
||||
secret, raw = fixture()
|
||||
before = copy.deepcopy(secret)
|
||||
encoded, changed = module.replacement(secret)
|
||||
updated = decode(encoded)
|
||||
self.assertTrue(changed)
|
||||
head = raw[:raw.index('tokenLifetime:')]
|
||||
self.assertTrue(updated.startswith(head))
|
||||
self.assertTrue(updated.endswith(raw[len(head):]))
|
||||
expected = yaml.safe_load(raw)
|
||||
expected['clients'].append(module.CLIENT)
|
||||
self.assertEqual(yaml.safe_load(updated), expected)
|
||||
self.assertEqual(secret, before) # input, including the signing key, untouched
|
||||
|
||||
def test_add_at_end_of_document(self):
|
||||
secret, raw = fixture(tail='')
|
||||
updated = decode(module.replacement(secret)[0])
|
||||
self.assertTrue(updated.startswith(raw))
|
||||
self.assertEqual(yaml.safe_load(updated)['clients'][-1], module.CLIENT)
|
||||
|
||||
def test_identical_registration_is_noop(self):
|
||||
secret, _ = fixture()
|
||||
secret['data']['config.yaml'] = module.replacement(secret)[0]
|
||||
self.assertEqual(module.replacement(secret), (secret['data']['config.yaml'], False))
|
||||
|
||||
def test_drift_fails_closed(self):
|
||||
for change in [dict(allowedScopes=['openbao:login', 'openbao:admin']),
|
||||
dict(audience='openbao'), dict(tokenLifetime='1h'),
|
||||
dict(roles=['secrets-engine', 'platform-admin']),
|
||||
dict(tenant='tenant:other'), dict(clientType='public'),
|
||||
dict(serviceSubject='service:other')]:
|
||||
with self.subTest(change=change), self.assertRaises(module.Refused):
|
||||
module.replacement(fixture(EXISTING + [dict(module.CLIENT, **change)])[0])
|
||||
|
||||
def test_duplicates_fail_closed(self):
|
||||
for clients in [EXISTING + [module.CLIENT, module.CLIENT],
|
||||
EXISTING + [EXISTING[0]]]:
|
||||
with self.assertRaises(module.Refused):
|
||||
module.replacement(fixture(clients)[0])
|
||||
with self.assertRaises(module.Refused):
|
||||
module.removal(fixture(clients)[0])
|
||||
|
||||
def test_secret_ref_reused_by_another_client_fails_closed(self):
|
||||
other = dict(EXISTING[1], clientId='other', secretRef=module.CLIENT['secretRef'])
|
||||
with self.assertRaises(module.Refused):
|
||||
module.replacement(fixture(EXISTING + [other])[0])
|
||||
|
||||
def test_unverified_upstream_issuer_refused(self):
|
||||
secret, raw = fixture()
|
||||
raw = raw.replace('https://auth.coulomb.social', 'https://auth.example.invalid')
|
||||
secret['data']['config.yaml'] = base64.b64encode(raw.encode()).decode()
|
||||
with self.assertRaises(module.Refused):
|
||||
module.replacement(secret)
|
||||
|
||||
def test_rollback_removes_only_this_client_and_restores_exact_bytes(self):
|
||||
for tail in ['tokenLifetime: 15m\n', '']:
|
||||
secret, raw = fixture(tail=tail)
|
||||
secret['data']['config.yaml'] = module.replacement(secret)[0]
|
||||
encoded, changed = module.removal(secret)
|
||||
self.assertTrue(changed)
|
||||
self.assertEqual(decode(encoded), raw)
|
||||
|
||||
def test_rollback_of_middle_entry_preserves_neighbours(self):
|
||||
secret, raw = fixture(EXISTING[:1] + [module.CLIENT] + EXISTING[1:])
|
||||
updated = decode(module.removal(secret)[0])
|
||||
self.assertEqual(updated, fixture()[1])
|
||||
|
||||
def test_rollback_absent_is_noop_and_drift_refused(self):
|
||||
secret, _ = fixture()
|
||||
self.assertEqual(module.removal(secret), (secret['data']['config.yaml'], False))
|
||||
with self.assertRaises(module.Refused):
|
||||
module.removal(fixture(EXISTING + [dict(module.CLIENT, tokenLifetime='1h')])[0])
|
||||
|
||||
def test_registration_matches_keycape_source(self):
|
||||
if not KEYCAPE_EXAMPLE.is_file():
|
||||
self.skipTest('key-cape checkout absent')
|
||||
source = yaml.safe_load(KEYCAPE_EXAMPLE.read_text())['clients']
|
||||
self.assertEqual([c for c in source if c['clientId'] == module.CLIENT_ID], [module.CLIENT])
|
||||
|
||||
|
||||
class DeploymentBindingTests(unittest.TestCase):
|
||||
def deployment(self, env):
|
||||
return {'spec': {'template': {'spec': {'containers': [{'name': 'keycape', 'env': env}]}}}}
|
||||
|
||||
def test_proposed_patch_carries_exactly_the_one_binding(self):
|
||||
patch = yaml.safe_load(Path(__file__).with_name(
|
||||
'secrets-engine-openbao-client.proposed.patch.yaml').read_text())
|
||||
container = patch['spec']['template']['spec']['containers'][0]
|
||||
self.assertEqual(container, {'name': 'keycape', 'env': [module.ENV_BINDING]})
|
||||
|
||||
def test_live_deployment_yaml_does_not_yet_bind(self):
|
||||
docs = yaml.safe_load_all(Path(__file__).with_name('deployment.yaml').read_text())
|
||||
dep = next(d for d in docs if d and d.get('kind') == 'Deployment')
|
||||
self.assertFalse(module.deployment_binding(dep))
|
||||
|
||||
def test_binding_detection_and_drift(self):
|
||||
self.assertTrue(module.deployment_binding(self.deployment([module.ENV_BINDING])))
|
||||
wrong = {'name': module.ENV_NAME, 'value': 'synthetic'}
|
||||
with self.assertRaises(module.Refused):
|
||||
module.deployment_binding(self.deployment([wrong]))
|
||||
|
||||
|
||||
class RunModeTests(unittest.TestCase):
|
||||
"""Drive run() against a fake kubectl; nothing reaches a cluster."""
|
||||
|
||||
def setUp(self):
|
||||
self.secret, _ = fixture()
|
||||
self.calls = []
|
||||
self.original = module.lane.kubectl
|
||||
self.env = []
|
||||
|
||||
def fake(arguments, payload=None):
|
||||
self.calls.append((arguments, payload))
|
||||
if arguments[:2] == ['get', 'namespace']:
|
||||
return {'metadata': {'uid': module.lane.CLUSTER_UID}}
|
||||
if 'deployment' in arguments:
|
||||
return {'spec': {'template': {'spec': {'containers': [{'name': 'keycape', 'env': self.env}]}}}}
|
||||
if 'patch' in arguments:
|
||||
return {'data': dict(self.secret['data'], **{'config.yaml': payload[-1]['value']})}
|
||||
return copy.deepcopy(self.secret)
|
||||
module.lane.kubectl = fake
|
||||
|
||||
def tearDown(self):
|
||||
module.lane.kubectl = self.original
|
||||
|
||||
def main(self, *argv):
|
||||
out = io.StringIO()
|
||||
with contextlib.redirect_stdout(out):
|
||||
code = module.main(list(argv))
|
||||
return code, json.loads(out.getvalue())
|
||||
|
||||
def test_default_is_inspect_and_never_patches(self):
|
||||
code, receipt = self.main()
|
||||
self.assertEqual(code, 0)
|
||||
self.assertEqual(receipt['mode'], 'inspect')
|
||||
self.assertTrue(receipt['change_needed'])
|
||||
self.assertFalse(any('patch' in a for a, _ in self.calls))
|
||||
self.assertNotIn('synthetic', json.dumps(receipt))
|
||||
|
||||
def test_dry_run_is_server_side(self):
|
||||
code, _ = self.main('dry-run', '--expected-uid', UID, '--expected-resource-version', '7')
|
||||
self.assertEqual(code, 0)
|
||||
patches = [a for a, _ in self.calls if 'patch' in a]
|
||||
self.assertEqual(len(patches), 1)
|
||||
self.assertIn('--dry-run=server', patches[0])
|
||||
|
||||
def test_apply_requires_live_go_and_env_binding(self):
|
||||
base = ['apply', '--expected-uid', UID, '--expected-resource-version', '7']
|
||||
self.assertEqual(self.main(*base), (1, {'status': 'refused', 'reason': 'founder_live_go_required'}))
|
||||
self.assertEqual(self.main(*base, '--live-go-decision', module.LIVE_GO_DECISION),
|
||||
(1, {'status': 'refused', 'reason': 'deployment_env_binding_required_first'}))
|
||||
self.assertFalse(any('patch' in a for a, _ in self.calls))
|
||||
|
||||
def test_stale_revision_refused_before_patch(self):
|
||||
code, receipt = self.main('dry-run', '--expected-uid', UID, '--expected-resource-version', '6')
|
||||
self.assertEqual(receipt, {'status': 'refused', 'reason': 'observed_revision_changed'})
|
||||
self.assertFalse(any('patch' in a for a, _ in self.calls))
|
||||
|
||||
def test_unexpected_errors_are_contained(self):
|
||||
self.secret['data']['config.yaml'] = 'not base64 !!'
|
||||
self.assertEqual(self.main(), (1, {'status': 'failed', 'reason': 'contained_operation_failed'}))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
---
|
||||
id: NK-WP-0046
|
||||
type: workplan
|
||||
title: "Deployment-owned registration of the secrets-engine-openbao KeyCape client"
|
||||
domain: infotech
|
||||
repo: net-kingdom
|
||||
status: active
|
||||
flavor: implementation
|
||||
owner: claude-code
|
||||
topic_slug: netkingdom
|
||||
created: "2026-10-06"
|
||||
updated: "2026-10-06"
|
||||
related: [KEY-WP-0037, RPF-WP-0035]
|
||||
---
|
||||
|
||||
KeyCape KEY-WP-0037-T03 needs NetKingdom, which owns `sso/keycape` and its
|
||||
config, to register the confidential service client `secrets-engine-openbao`
|
||||
(subject service:secrets-engine, tenant tenant:platform, role secrets-engine,
|
||||
scope openbao:login, client_credentials, 15m, secretRef
|
||||
env:KEYCAPE_SECRETS_ENGINE_OPENBAO_CLIENT_SECRET, no resource-audience
|
||||
override). Custody boundary 632a8893 is approved; this plan prepares source
|
||||
only. No live change is authorized until decision
|
||||
caafe4be-4f68-47b4-9567-316d47b94992 is resolved.
|
||||
|
||||
## Prepare the owner-specific merge, env binding and tests
|
||||
|
||||
```task
|
||||
id: NK-WP-0046-T01
|
||||
status: done
|
||||
priority: high
|
||||
```
|
||||
|
||||
`sso-mfa/k8s/keycape/secrets-engine-openbao-client-rollout.py` (exact-match
|
||||
add/noop/refuse, byte-preserving; `--operation remove` rollback; default
|
||||
`inspect`, `apply` gated on the live-go decision id and the env binding),
|
||||
`secrets-engine-openbao-client.proposed.patch.yaml` (one env secretKeyRef from
|
||||
`keycape-secrets-engine-openbao-client`/`client-secret`) and
|
||||
`test_secrets_engine_openbao_client_rollout.py` (synthetic fixtures). The
|
||||
registration is asserted equal to KeyCape `config/service-clients.example.yaml`
|
||||
at e22029e. No cluster, OpenBao or ESO was read or written.
|
||||
|
||||
## Choose the reviewed image revision and digest
|
||||
|
||||
```task
|
||||
id: NK-WP-0046-T02
|
||||
status: todo
|
||||
priority: high
|
||||
```
|
||||
|
||||
Deployed pin in Git (`deployment.yaml`, since 019e8f2, 2026-09-28):
|
||||
`forgejo.coulomb.social/coulomb/key-cape@sha256:6f79a2af1c695d39480173fad013facd84d21e7732860366af519302a2c496b8`,
|
||||
built from KeyCape source 1164f65 per `history/2026-09-28-telemetry-admin-role.md`.
|
||||
KeyCape main HEAD is e22029e. Decide whether the registration rides the
|
||||
deployed image (the client needs no new issuer code) or a newer reviewed
|
||||
digest; record the exact source revision and digest before the go. A newer
|
||||
binary must be checked for startup refusals against the live config.
|
||||
|
||||
## Live registration rollout under the founder's go
|
||||
|
||||
```task
|
||||
id: NK-WP-0046-T03
|
||||
status: wait
|
||||
priority: high
|
||||
needs_human: true
|
||||
decision_id: caafe4be-4f68-47b4-9567-316d47b94992
|
||||
depends_on:
|
||||
- NK-WP-0046-T02
|
||||
blocking_reason: "Requires the founder's separate live go (caafe4be), the platform-materialised Secret keycape-secrets-engine-openbao-client and the chosen image pin."
|
||||
```
|
||||
|
||||
Attended operator only: server dry-run and diff of the env patch and the
|
||||
config merge, check headroom, apply the env patch, `apply` the merge with the
|
||||
observed uid/resourceVersion, restart, then hand to KEY-WP-0037-T04 for native
|
||||
issuance, wrong-secret/excess-scope denial and existing human-login checks.
|
||||
Rollback: `--operation remove`, restart, remove the env binding. Fold the
|
||||
binding into `deployment.yaml` in the same change that records the go.
|
||||
Loading…
Add table
Add a link
Reference in a new issue