Preserve writable OIDC fields on the loopback callback update.
The second attended attempt spawned the owner command, then failed closed. Stop posting the entire role read-back and requiring exact dict equality. Write a metadata receipt so the next failure has a class, not silence. Assistant: grok Assistant-Session: 01a0a23b-3bf0-7341-b4e5-9dc05f72573a
This commit is contained in:
parent
7496d9fab5
commit
041f6bdc51
4 changed files with 185 additions and 27 deletions
|
|
@ -37,10 +37,16 @@ silent and Warden self-revokes the attended session:
|
||||||
warden plan \
|
warden plan \
|
||||||
"attended OpenBao platform administration to add the exact operator-tunneled OIDC callback to auth/netkingdom/role/platform-admin" \
|
"attended OpenBao platform administration to add the exact operator-tunneled OIDC callback to auth/netkingdom/role/platform-admin" \
|
||||||
--json
|
--json
|
||||||
python3 scripts/openbao-attended-exec.py -- \
|
python3 /home/worsch/railiance-platform/scripts/openbao-attended-exec.py -- \
|
||||||
/home/worsch/railiance-platform/scripts/openbao-apply-operator-loopback-callback.sh
|
/home/worsch/railiance-platform/scripts/openbao-apply-operator-loopback-callback.sh \
|
||||||
|
--receipt /tmp/openbao-loopback-callback.json
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The child stays silent. Metadata-only status lands in the receipt: `applied`,
|
||||||
|
`already_present`, or a failure class. Do not reuse a receipt path that already
|
||||||
|
exists. The helper writes only known OIDC role fields so a live read-back blob
|
||||||
|
cannot fail the update.
|
||||||
|
|
||||||
The plan must return `founder_required` and select
|
The plan must return `founder_required` and select
|
||||||
`openbao-platform-admin-login`. The owner command must be an absolute path:
|
`openbao-platform-admin-login`. The owner command must be an absolute path:
|
||||||
Warden's contained child inherits the caller's cwd, so a relative `scripts/...`
|
Warden's contained child inherits the caller's cwd, so a relative `scripts/...`
|
||||||
|
|
|
||||||
|
|
@ -1,49 +1,154 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Silent contained callback update; preserve the existing administrator role."""
|
"""Silent contained callback update; preserve the existing administrator role."""
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
import tempfile
|
||||||
|
|
||||||
ROLE = 'auth/netkingdom/role/platform-admin'
|
ROLE = 'auth/netkingdom/role/platform-admin'
|
||||||
CALLBACK = 'http://127.0.0.1:18200/ui/vault/auth/netkingdom/oidc/callback'
|
CALLBACK = 'http://127.0.0.1:18200/ui/vault/auth/netkingdom/oidc/callback'
|
||||||
|
WRITABLE = (
|
||||||
|
'role_type', 'user_claim', 'user_claim_json_pointer', 'groups_claim',
|
||||||
|
'bound_claims', 'bound_claims_type', 'bound_audiences', 'bound_subject',
|
||||||
|
'claim_mappings', 'oidc_scopes', 'allowed_redirect_uris',
|
||||||
|
'clock_skew_leeway', 'expiration_leeway', 'not_before_leeway', 'max_age',
|
||||||
|
'verbose_oidc_logging', 'token_ttl', 'token_max_ttl', 'token_explicit_max_ttl',
|
||||||
|
'token_policies', 'token_bound_cidrs', 'token_no_default_policy',
|
||||||
|
'token_num_uses', 'token_period', 'token_type', 'policies', 'ttl', 'max_ttl',
|
||||||
|
'period', 'num_uses',
|
||||||
|
)
|
||||||
|
PRESERVED = (
|
||||||
|
'role_type', 'user_claim', 'groups_claim', 'bound_claims', 'claim_mappings',
|
||||||
|
'oidc_scopes', 'token_policies', 'policies', 'token_ttl', 'ttl',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Refused(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def require_attended():
|
||||||
|
if Path.home().parent.name != '.warden-attended-login' or os.getenv('BAO_TOKEN') or os.getenv('VAULT_TOKEN'):
|
||||||
|
raise Refused('attended_envelope_required')
|
||||||
|
|
||||||
|
|
||||||
|
def payload(role):
|
||||||
|
return {key: role[key] for key in WRITABLE if key in role and role[key] is not None}
|
||||||
|
|
||||||
|
|
||||||
|
def preserved(role):
|
||||||
|
return {key: role.get(key) for key in PRESERVED}
|
||||||
|
|
||||||
|
|
||||||
def read_role():
|
def read_role():
|
||||||
result = subprocess.run(['bao', 'read', '-format=json', ROLE],
|
result = subprocess.run(['bao', 'read', '-format=json', ROLE],
|
||||||
capture_output=True, check=True, timeout=30)
|
capture_output=True, check=True, timeout=30)
|
||||||
role = json.loads(result.stdout)['data']
|
role = json.loads(result.stdout)['data']
|
||||||
|
policies = role.get('token_policies') or role.get('policies') or []
|
||||||
if (role.get('role_type') != 'oidc'
|
if (role.get('role_type') != 'oidc'
|
||||||
or 'platform-admin' not in role.get('token_policies', role.get('policies', []))
|
or 'platform-admin' not in policies
|
||||||
or not isinstance(role.get('allowed_redirect_uris'), list)
|
or not isinstance(role.get('allowed_redirect_uris'), list)
|
||||||
or not all(isinstance(uri, str) for uri in role['allowed_redirect_uris'])):
|
or not all(isinstance(uri, str) for uri in role['allowed_redirect_uris'])):
|
||||||
raise ValueError('unexpected role')
|
raise Refused('unexpected_role')
|
||||||
return role
|
return role
|
||||||
|
|
||||||
|
|
||||||
def update(read=read_role, write=None):
|
def write_role(desired):
|
||||||
|
handle = tempfile.NamedTemporaryFile('w', encoding='utf-8', delete=False)
|
||||||
|
try:
|
||||||
|
json.dump(desired, handle)
|
||||||
|
handle.close()
|
||||||
|
subprocess.run(['bao', 'write', ROLE, '@' + handle.name],
|
||||||
|
capture_output=True, check=True, timeout=30)
|
||||||
|
finally:
|
||||||
|
Path(handle.name).unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def update(read=read_role, write=write_role):
|
||||||
original = read()
|
original = read()
|
||||||
if CALLBACK in original['allowed_redirect_uris']:
|
if CALLBACK in original['allowed_redirect_uris']:
|
||||||
return False
|
return False
|
||||||
desired = dict(original, allowed_redirect_uris=original['allowed_redirect_uris'] + [CALLBACK])
|
desired = dict(payload(original),
|
||||||
if read() != original:
|
allowed_redirect_uris=original['allowed_redirect_uris'] + [CALLBACK])
|
||||||
raise ValueError('role changed before write')
|
if preserved(read()) != preserved(original):
|
||||||
# The endpoint has no CAS: this detects observed drift, not an atomic lock.
|
raise Refused('role_changed_before_write')
|
||||||
if write is None:
|
write(desired)
|
||||||
subprocess.run(['bao', 'write', ROLE, '-'], input=json.dumps(desired).encode(),
|
after = read()
|
||||||
capture_output=True, check=True, timeout=30)
|
if CALLBACK not in after.get('allowed_redirect_uris', []):
|
||||||
else:
|
raise Refused('readback_callback_missing')
|
||||||
write(desired)
|
if preserved(after) != preserved(original):
|
||||||
if read() != desired:
|
raise Refused('readback_settings_changed')
|
||||||
raise ValueError('role readback differs')
|
if set(original['allowed_redirect_uris']) - set(after['allowed_redirect_uris']):
|
||||||
|
raise Refused('readback_existing_callback_dropped')
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
def write_receipt(path, status, **extra):
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
path = Path(path)
|
||||||
|
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
|
||||||
|
fd = os.open(path, flags, 0o600)
|
||||||
|
body = {
|
||||||
|
'schema': 'railiance-platform.openbao-loopback-callback.v1',
|
||||||
|
'observed_at': datetime.now(timezone.utc).isoformat(),
|
||||||
|
'role': ROLE, 'callback': CALLBACK, 'status': status,
|
||||||
|
'credential_values_emitted': False, **extra,
|
||||||
|
}
|
||||||
|
with os.fdopen(fd, 'w', encoding='utf-8') as handle:
|
||||||
|
json.dump(body, handle, indent=2, sort_keys=True)
|
||||||
|
handle.write('\n')
|
||||||
|
|
||||||
|
|
||||||
|
def classify(error):
|
||||||
|
if isinstance(error, Refused):
|
||||||
|
return str(error)
|
||||||
|
if isinstance(error, subprocess.CalledProcessError):
|
||||||
|
command = error.cmd[1] if isinstance(error.cmd, list) and len(error.cmd) > 1 else ''
|
||||||
|
return 'bao_write_failed' if command == 'write' else 'bao_read_failed'
|
||||||
|
return 'contained_operation_failed'
|
||||||
|
|
||||||
|
|
||||||
|
def parse(argv):
|
||||||
|
receipt = None
|
||||||
|
check_only = False
|
||||||
|
args = list(argv)
|
||||||
|
while args:
|
||||||
|
if args[0] == '--receipt':
|
||||||
|
if len(args) < 2:
|
||||||
|
raise SystemExit(2)
|
||||||
|
receipt = args[1]
|
||||||
|
args = args[2:]
|
||||||
|
elif args[0] == '--check-only':
|
||||||
|
check_only = True
|
||||||
|
args = args[1:]
|
||||||
|
else:
|
||||||
|
raise SystemExit(2)
|
||||||
|
return receipt, check_only
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv):
|
||||||
|
receipt, check_only = parse(argv)
|
||||||
try:
|
try:
|
||||||
if sys.argv[1:] == ['--check-only']:
|
if check_only:
|
||||||
sys.exit(0 if CALLBACK in read_role()['allowed_redirect_uris'] else 3)
|
present = CALLBACK in read_role()['allowed_redirect_uris']
|
||||||
if sys.argv[1:]:
|
if receipt:
|
||||||
sys.exit(2)
|
write_receipt(receipt, 'present' if present else 'absent')
|
||||||
update()
|
return 0 if present else 3
|
||||||
except Exception:
|
require_attended()
|
||||||
sys.exit(1)
|
changed = update()
|
||||||
|
if receipt:
|
||||||
|
write_receipt(receipt, 'applied' if changed else 'already_present', changed=changed)
|
||||||
|
return 0
|
||||||
|
except Exception as error:
|
||||||
|
if receipt:
|
||||||
|
try:
|
||||||
|
write_receipt(receipt, classify(error))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
raise SystemExit(main(sys.argv[1:]))
|
||||||
|
|
|
||||||
|
|
@ -29,14 +29,36 @@ def test_preserves_all_other_settings():
|
||||||
assert len(writes) == 1
|
assert len(writes) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_payload_omits_read_only_fields():
|
||||||
|
current = dict(role(), request_id='synthetic', lease_duration=0)
|
||||||
|
writes = []
|
||||||
|
def write(value):
|
||||||
|
writes.append(value)
|
||||||
|
current.update(value)
|
||||||
|
assert m.update(lambda: copy.deepcopy(current), write)
|
||||||
|
assert 'request_id' not in writes[0]
|
||||||
|
assert 'lease_duration' not in writes[0]
|
||||||
|
assert writes[0]['allowed_redirect_uris'][-1] == m.CALLBACK
|
||||||
|
|
||||||
|
|
||||||
|
def test_readback_allows_normalized_extra_fields():
|
||||||
|
current = role()
|
||||||
|
def read():
|
||||||
|
return dict(current, lease_duration=0)
|
||||||
|
def write(value):
|
||||||
|
current.update(value)
|
||||||
|
assert m.update(read, write)
|
||||||
|
assert m.CALLBACK in current['allowed_redirect_uris']
|
||||||
|
|
||||||
|
|
||||||
def test_observed_concurrent_change_prevents_write():
|
def test_observed_concurrent_change_prevents_write():
|
||||||
reads = iter([role(), dict(role(), token_ttl=300)])
|
reads = iter([role(), dict(role(), token_ttl=300)])
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(m.Refused, match='role_changed_before_write'):
|
||||||
m.update(lambda: next(reads), lambda _: pytest.fail('must not write'))
|
m.update(lambda: next(reads), lambda _: pytest.fail('must not write'))
|
||||||
|
|
||||||
|
|
||||||
def test_failed_readback_is_not_success():
|
def test_failed_readback_is_not_success():
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(m.Refused, match='readback_callback_missing'):
|
||||||
m.update(role, lambda _: None)
|
m.update(role, lambda _: None)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -46,10 +68,25 @@ def test_unexpected_live_role_refused(monkeypatch, mutation):
|
||||||
import subprocess
|
import subprocess
|
||||||
payload = dict(role(), **mutation)
|
payload = dict(role(), **mutation)
|
||||||
monkeypatch.setattr(m.subprocess, 'run', lambda *a, **kw: subprocess.CompletedProcess(a, 0, json.dumps({'data': payload}).encode()))
|
monkeypatch.setattr(m.subprocess, 'run', lambda *a, **kw: subprocess.CompletedProcess(a, 0, json.dumps({'data': payload}).encode()))
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(m.Refused, match='unexpected_role'):
|
||||||
m.read_role()
|
m.read_role()
|
||||||
|
|
||||||
|
|
||||||
|
def test_receipt_records_apply_and_failure(tmp_path, monkeypatch):
|
||||||
|
import json
|
||||||
|
receipt = tmp_path / 'loopback.json'
|
||||||
|
monkeypatch.setattr(m, 'require_attended', lambda: None)
|
||||||
|
monkeypatch.setattr(m, 'update', lambda: True)
|
||||||
|
assert m.main(['--receipt', str(receipt)]) == 0
|
||||||
|
assert json.loads(receipt.read_text())['status'] == 'applied'
|
||||||
|
failed = tmp_path / 'failed.json'
|
||||||
|
def boom():
|
||||||
|
raise m.Refused('bao_write_failed')
|
||||||
|
monkeypatch.setattr(m, 'update', boom)
|
||||||
|
assert m.main(['--receipt', str(failed)]) == 1
|
||||||
|
assert json.loads(failed.read_text())['status'] == 'bao_write_failed'
|
||||||
|
|
||||||
|
|
||||||
def test_attended_wrapper_requires_absolute_existing_executable(tmp_path, monkeypatch):
|
def test_attended_wrapper_requires_absolute_existing_executable(tmp_path, monkeypatch):
|
||||||
import os
|
import os
|
||||||
spec = importlib.util.spec_from_file_location(
|
spec = importlib.util.spec_from_file_location(
|
||||||
|
|
|
||||||
|
|
@ -121,3 +121,13 @@ the session was revoked and private storage cleaned. No role write, Ingress
|
||||||
change, or retained helper. Retry only through
|
change, or retained helper. Retry only through
|
||||||
`python3 scripts/openbao-attended-exec.py --` and the absolute owner command.
|
`python3 scripts/openbao-attended-exec.py --` and the absolute owner command.
|
||||||
Do not reuse the failed relative-path attempt as callback evidence.
|
Do not reuse the failed relative-path attempt as callback evidence.
|
||||||
|
|
||||||
|
A second attended attempt used the absolute owner command. Login reached a
|
||||||
|
helper-backed session and the child started, then Warden reported
|
||||||
|
`attended command failed closed because it returned a failure or unexpected
|
||||||
|
output; the login session was revoked`. That string means the silent helper
|
||||||
|
exited non-zero. The helper previously posted the entire role read-back and
|
||||||
|
required exact dict equality; that path was never live-proven and can fail on
|
||||||
|
read-only/normalized fields. No retained helper. Retry uses the same envelope
|
||||||
|
with `--receipt` and a field-preserving write. Do not retract Ingress from this
|
||||||
|
failed attempt.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue