railiance-platform/scripts/operators_entry_retire.py
codex 7b75823095
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
ADHOC-2026-10-10: retire the deprecated operators/nextcloud entry
The founder confirmed operators/nextcloud (not /backup) is deprecated. The
operators mount is deliberately delete-free for operator-custody and absent
from platform-admin, so a silent helper performs the deletion with a one-shot
policy and a 60s orphan token scoped to that entry. It verifies the backup
sibling is unchanged and removes the token and policy afterwards.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 469992@bnt-lap001
Assistant-Session: e4b8e420-df0a-441d-83ae-7f19130594d9
2026-10-10 15:32:02 +02:00

196 lines
7.4 KiB
Python

#!/usr/bin/env python3
"""Attended, silent retirement of the deprecated operators/nextcloud entry.
Founder decision 2026-10-10: ``operators/nextcloud`` (the base entry, not
``operators/nextcloud/backup``) is a deprecated credential and is removed. The
operators mount is deliberately delete-free for ``operator-custody`` and absent
from ``platform-admin`` (openbao/policies/operator-custody.hcl: "destroying a
version is a separate, deliberate act under a broader identity"). This helper is
that act, and it grants nothing standing:
1. As platform-admin (warden attended envelope), write one-shot policy
``operators-retire-nextcloud``: ``delete`` and ``read`` on
``operators/metadata/nextcloud``, ``read`` on
``operators/metadata/nextcloud/backup``, ``revoke-self``, nothing else.
2. Mint an orphan token with only that policy (60s, 8 uses, no default policy).
3. With that token: prove the entry exists (record its version count) and that
``nextcloud/backup`` exists; metadata-delete ``operators/nextcloud``; prove it
is absent and that ``nextcloud/backup`` is unchanged.
4. Revoke the token and delete the one-shot policy, on every path.
No value is read. Run from a railiance-platform checkout:
BAO_ADDR=http://127.0.0.1:18200 VAULT_ADDR=http://127.0.0.1:18200 \\
warden access openbao-platform-admin-login --exec -- \\
python3 scripts/operators_entry_retire.py \\
--confirm 'RETIRE operators/nextcloud' --receipt "$HOME/operators-nextcloud-retire.json"
``--dry-run`` runs steps 1, 2 and the existence checks, deletes nothing, and
cleans up. The process prints nothing; the receipt (0600, refused if it exists)
holds versions, booleans and an outcome code.
Exit codes: 0 retired / dry-run clean / already absent; 1 contained internal
error; 2 precondition refused; 4 policy, token or delete failure; 5 verification
failure (entry still present, or the sibling changed).
"""
from __future__ import annotations
import argparse
import json
import os
from pathlib import Path
import subprocess
import sys
import tempfile
ENTRY = 'nextcloud'
SIBLING = 'nextcloud/backup'
POLICY = 'operators-retire-nextcloud'
CONFIRM = 'RETIRE operators/nextcloud'
SCHEMA = 'platform.operators-entry-retire.v1'
POLICY_HCL = f'''# One-shot: {CONFIRM} (founder decision 2026-10-10). Deleted after use.
path "operators/metadata/{ENTRY}" {{
capabilities = ["read", "delete"]
}}
path "operators/metadata/{SIBLING}" {{
capabilities = ["read"]
}}
path "auth/token/revoke-self" {{
capabilities = ["update"]
}}
'''
class Exit(Exception):
def __init__(self, code: int, reason: str):
super().__init__(reason)
self.code = code
self.reason = reason
def bao(args, token=None):
env = dict(os.environ)
if token is not None:
env['BAO_TOKEN'] = env['VAULT_TOKEN'] = token
return subprocess.run(['bao', *args], env=env, capture_output=True, timeout=60)
def meta(path, token):
"""Version count of a KV metadata entry, None if provably absent."""
result = bao(['read', '-format=json', f'operators/metadata/{path}'], token=token)
if result.returncode != 0:
if b'404' in result.stderr or b'No value found' in result.stderr:
return None
raise Exit(2, 'metadata_read_failed')
try:
data = json.loads(result.stdout)['data']
return {'current_version': data['current_version'], 'versions': len(data.get('versions') or {})}
except (ValueError, KeyError, TypeError):
raise Exit(2, 'metadata_unparseable') from None
def run(args, receipt, state):
if args.confirm != CONFIRM:
raise Exit(2, 'exact_confirmation_required')
if (Path.home().parent.name != '.warden-attended-login'
or os.environ.get('BAO_TOKEN') or os.environ.get('VAULT_TOKEN')):
raise Exit(2, 'attended_warden_envelope_required')
ident = bao(['token', 'lookup', '-format=json'])
if ident.returncode != 0:
raise Exit(2, 'identity_lookup_failed')
policies = (json.loads(ident.stdout).get('data') or {}).get('policies') or []
if 'platform-admin' not in policies or 'root' in policies:
raise Exit(2, 'attended_platform_admin_required')
with tempfile.TemporaryDirectory() as tmp:
hcl = Path(tmp) / 'policy.hcl'
hcl.write_text(POLICY_HCL)
if bao(['policy', 'write', POLICY, str(hcl)]).returncode != 0:
raise Exit(4, 'policy_write_failed')
state['policy'] = True
created = bao(['token', 'create', '-orphan', f'-policy={POLICY}', '-no-default-policy',
'-ttl=60s', '-explicit-max-ttl=60s', '-use-limit=8', '-format=json'])
if created.returncode != 0:
raise Exit(4, 'token_create_failed')
try:
state['token'] = json.loads(created.stdout)['auth']['client_token']
except (ValueError, KeyError, TypeError):
raise Exit(4, 'token_create_failed') from None
token = state['token']
before = meta(ENTRY, token)
sibling_before = meta(SIBLING, token)
receipt['entry_before'] = before
receipt['sibling_before'] = sibling_before
if sibling_before is None:
raise Exit(2, 'sibling_absent_refusing_to_guess_layout')
if before is None:
return 'already_absent'
if args.dry_run:
return 'dry_run_would_retire'
if bao(['delete', f'operators/metadata/{ENTRY}'], token=token).returncode != 0:
raise Exit(4, 'delete_failed')
if meta(ENTRY, token) is not None:
raise Exit(5, 'entry_still_present')
sibling_after = meta(SIBLING, token)
receipt['sibling_after'] = sibling_after
if sibling_after != sibling_before:
raise Exit(5, 'sibling_changed')
receipt['entry_deleted'] = True
return 'retired'
def cleanup(receipt, state):
if state.get('token'):
receipt['token_revoked'] = bao(['token', 'revoke', '-self'], token=state['token']).returncode == 0
if state.get('policy'):
receipt['policy_deleted'] = bao(['policy', 'delete', POLICY]).returncode == 0
def main(argv=None):
devnull = os.open(os.devnull, os.O_WRONLY)
os.dup2(devnull, 1)
os.dup2(devnull, 2)
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--confirm', default='')
parser.add_argument('--receipt', type=Path)
parser.add_argument('--dry-run', action='store_true')
try:
args = parser.parse_args(argv)
except SystemExit:
return 2
receipt = {'schema': SCHEMA, 'mode': 'dry-run' if args.dry_run else 'retire',
'entry': f'operators/{ENTRY}', 'sibling': f'operators/{SIBLING}', 'value_reads': 0}
fd = None
if args.receipt is not None:
try:
fd = os.open(args.receipt, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
except OSError:
return 2
state = {}
try:
receipt['outcome'] = run(args, receipt, state)
code = 0
except Exit as exc:
receipt['outcome'] = exc.reason
code = exc.code
except Exception:
receipt['outcome'] = 'contained_internal_error'
code = 1
try:
cleanup(receipt, state)
except Exception:
receipt['cleanup_error'] = True
if code == 0 and state.get('policy') and not receipt.get('policy_deleted'):
receipt['outcome'] += '_policy_cleanup_failed'
code = 4
receipt['exit_code'] = code
if fd is not None:
os.write(fd, (json.dumps(receipt, indent=2, sort_keys=True) + '\n').encode())
os.close(fd)
return code
if __name__ == '__main__':
sys.exit(main())