From e7eb9db57e5c9d5b762e02e995202d5d76e77d4d Mon Sep 17 00:00:00 2001 From: codex Date: Sat, 3 Oct 2026 08:28:32 +0200 Subject: [PATCH] CUST-WP-0075: tenant-engine sender seed helper; re-qualify RPF-WP-0035-T02/T08 - scripts/tenant_engine_audit_core_sender_seed.py: silent attended row-7 seed for MASON-WP-0007-T05 (wrap-migrate technique, CAS 0, never overwrites, metadata-only --dry-run, documented exit codes), with offline fake-bao tests. - Lane review doc with the exact founder sequence and the read-only ESO token-create can-i check for MASON-WP-0007-T06. - RPF-WP-0035-T02: SECRETS-WP-0007-T04 is done; real blocker is live KeyCape registration and client custody of secrets-engine-openbao (blocked_on key-cape). Design stays proposed. - RPF-WP-0035-T08: receiver rollout with evidence_kind already accepted 2026-09-11; moved the misplaced D1 correction note here from T02. Co-Authored-By: Claude Opus 5.5 Assistant: claude-code Assistant-Model: opus Assistant-Process: 3775@bnt-lap001 Assistant-Session: cfacb6a5-ad5b-4f79-ac67-053e3b0d34a3 --- .../secrets-engine-service-jwt.md | 9 + .../tenant-engine-audit-core-sender-seed.md | 93 ++++++++ intakes/intakes.md | 3 + .../tenant_engine_audit_core_sender_seed.py | 216 ++++++++++++++++++ ...st_tenant_engine_audit_core_sender_seed.py | 183 +++++++++++++++ ...-WP-0035-credential-lane-implementation.md | 11 +- 6 files changed, 512 insertions(+), 3 deletions(-) create mode 100644 docs/credential-lane-designs/tenant-engine-audit-core-sender-seed.md create mode 100755 scripts/tenant_engine_audit_core_sender_seed.py create mode 100644 tests/test_tenant_engine_audit_core_sender_seed.py diff --git a/docs/credential-lane-designs/secrets-engine-service-jwt.md b/docs/credential-lane-designs/secrets-engine-service-jwt.md index ce7e05f..6724c6b 100644 --- a/docs/credential-lane-designs/secrets-engine-service-jwt.md +++ b/docs/credential-lane-designs/secrets-engine-service-jwt.md @@ -120,3 +120,12 @@ then perform the positive/negative/expiry/use-limit/revocation checks above. Do not print login responses. Publish the consumer contract only after those checks pass. A conflicting existing mount/role requires review, not overwrite. No service-client secret is created, moved or requested by this source return. + +## Gate check — 2026-10-03 (CUST-WP-0075) + +Still proposed. SECRETS-WP-0007-T04 is done (2026-09-27), so the gate named +above is open, but the "before writing" preconditions are not: KeyCape has not +claimed a live registration of `secrets-engine-openbao`, and its client-credential +custody has no owning task (KeyCape contract: "a separate approved custody +action"). No reviewed child is written. RPF-WP-0035-T02 waits on key-cape naming +that task. diff --git a/docs/credential-lane-designs/tenant-engine-audit-core-sender-seed.md b/docs/credential-lane-designs/tenant-engine-audit-core-sender-seed.md new file mode 100644 index 0000000..57058b9 --- /dev/null +++ b/docs/credential-lane-designs/tenant-engine-audit-core-sender-seed.md @@ -0,0 +1,93 @@ +# tenant-engine audit-core sender: attended seed + +Status: ready for review, not run. Owner: railiance-platform (OpenBao side) for +ops-mason MASON-WP-0007-T05 (plan row 7). Intake RPF-IN-0002. Prepared +2026-10-03 under CUST-WP-0075. + +## What the founder approves + +One attended sitting, run from a railiance-platform checkout at the reviewed +commit, with these three children in order. Each child prints nothing; read +warden's printed line, not its exit code (orientation §5). + +```sh +cd ~/railiance-platform +export BAO_ADDR=http://127.0.0.1:18200 VAULT_ADDR=http://127.0.0.1:18200 + +# 1. policy + Kubernetes-auth role (commit 8c44256). Exit 3 = existing object differs. +warden access openbao-platform-admin-login --exec -- \ + sh scripts/openbao-tenant-engine-audit-core-sender-eso-attended.sh + +# 2. seed dry-run: KV metadata only, no value read or written. +warden access openbao-platform-admin-login --exec -- \ + python3 scripts/tenant_engine_audit_core_sender_seed.py --dry-run \ + --confirm 'SEED tenant-engine audit-core-sender' \ + --receipt docs/evidence/tenant-engine-sender-seed-dry-run.json + +# 3. seed: only if step 2's receipt says outcome dry_run_would_write (exit 0). +warden access openbao-platform-admin-login --exec -- \ + python3 scripts/tenant_engine_audit_core_sender_seed.py \ + --confirm 'SEED tenant-engine audit-core-sender' \ + --receipt docs/evidence/tenant-engine-sender-seed.json +``` + +Each `warden access` is its own login. Receipts are created mode 0600, refused +if the file exists, and hold only paths, versions, booleans and an outcome +code. Commit them as evidence. + +## What the seed does + +`scripts/tenant_engine_audit_core_sender_seed.py` is the user-engine / +audit-core wrap-migrate technique (ops-mason +`plans/user-engine-openbao-runtime-custody.md` §6; audit-core runbook, +"wrap-migrated on 2026-08-14"): one platform-admin process reads the source and +writes the target, and the value travels only through process memory and the +`bao` child's stdin. Never argv, stdout, stderr, a file or the receipt. + +1. Refuses unless `--confirm` is exact, HOME is a warden attended envelope, no + `BAO_TOKEN`/`VAULT_TOKEN` is set, and the identity has `platform-admin` and + not `root`. +2. Reads `platform/workloads/audit-core/senders` (KV v2, field `senders.json`) + and selects the one row named `tenant-engine`. Refuses if the row is + missing or duplicated, holds zero or more than one token (a rotation + overlap is in progress: finish it first), or is not write-only + (`may_write` true, `may_read` false). +3. If `platform/workloads/tenant-engine/audit-core-sender` exists: exit 0 when + it holds exactly `{token: }`, otherwise exit 3. Never overwrites. +4. Otherwise writes `{token}` with `cas=0`, reads it back (must be version 1 and + equal), and checks that the registry version did not move during the run. + +The registry is never written. Exit codes: 0 ok (written or already +identical; dry-run: would write), 1 contained internal error, 2 precondition +refused before any write, 3 target exists and differs, 4 write failed, +5 verify failed, 6 dry-run found the target already present. + +Tests: `tests/test_tenant_engine_audit_core_sender_seed.py` runs the helper as +a subprocess against a fake `bao` and asserts empty stdout/stderr, the token +never in `bao` argv or the receipt, CAS 0, idempotence, refusal of a differing +or deleted target, every precondition, and distinct write/verify codes. + +## Operator check for MASON-WP-0007-T06 (read-only, not run) + +Before the tenant-engine ClusterSecretStore goes live, the ESO controller must +be allowed to create ServiceAccount tokens in `tenant-engine`. Confirm the node +IP is `92.205.62.239` first (`kubectl get nodes -o wide`), then: + +```sh +kubectl auth can-i create serviceaccounts --subresource=token \ + -n tenant-engine --as=system:serviceaccount:external-secrets:external-secrets +``` + +`yes` is required. This is a SubjectAccessReview: it reads no object. A `no` +is a railiance-cluster RBAC matter, not a reason to widen the OpenBao role. + +## Follow-ups outside the seed + +- `openbao/policies/agent-high-risk-boundary.hcl` denies coding agents the + sender registry, but not the new + `platform/{data,metadata}/workloads/tenant-engine/audit-core-sender` paths. + Adding those deny lines is a separate reviewed policy change. +- Rotation stays overlap-first (plan §4): rotate in the registry, finish the + overlap so the sender holds one token, then re-seed. Because this helper + never overwrites, a rotated token needs a reviewed CAS-update variant. That + variant is not built. diff --git a/intakes/intakes.md b/intakes/intakes.md index 9a0604d..ba0bda9 100644 --- a/intakes/intakes.md +++ b/intakes/intakes.md @@ -48,6 +48,9 @@ description: >- audience openbao, 15m ttl/max/explicit max, no default policy). Not applied. Open: attended apply, then the founder-attended KV seed (plan row 7). No RBAC change needed here (see message). + 2026-10-03 (CUST-WP-0075): row-7 seed helper scripts/tenant_engine_audit_core_sender_seed.py + (silent, CAS 0, never overwrites, metadata-only --dry-run) and lane review + docs/credential-lane-designs/tenant-engine-audit-core-sender-seed.md added; not run. state_hub_intake_id: "01a0fedd-7af5-7126-a263-647d438b068d" ``` diff --git a/scripts/tenant_engine_audit_core_sender_seed.py b/scripts/tenant_engine_audit_core_sender_seed.py new file mode 100755 index 0000000..507c801 --- /dev/null +++ b/scripts/tenant_engine_audit_core_sender_seed.py @@ -0,0 +1,216 @@ +#!/usr/bin/env python3 +"""MASON-WP-0007-T05 row 7: attended, silent seed of tenant-engine's sender token. + +Copies the one token of sender ``tenant-engine`` from the audit-core sender +registry (KV v2 ``platform/workloads/audit-core/senders``, field +``senders.json``) to the dedicated KV v2 path +``platform/workloads/tenant-engine/audit-core-sender`` (one field ``token``), +which the ESO role ``tenant-engine-audit-core-sender-eso`` reads. This is the +user-engine / audit-core wrap-migrate technique: one platform-admin process +reads the source and writes the target; the value only ever travels through +this process's memory and the ``bao`` child's stdin, never argv, stdout, +stderr, a file or the receipt. + +Run from a railiance-platform checkout at the reviewed commit, after +``scripts/openbao-tenant-engine-audit-core-sender-eso-attended.sh``: + + 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/tenant_engine_audit_core_sender_seed.py \\ + --confirm 'SEED tenant-engine audit-core-sender' \\ + --receipt "$HOME/te-sender-seed-receipt.json" + +Add ``--dry-run`` to touch only KV metadata (no value is read or written). + +The process prints nothing: stdout and stderr are redirected to /dev/null at +start (warden fails closed on any child output). The optional ``--receipt`` +(created mode 0600, refused if it already exists) holds only versions, +booleans and a fixed outcome code. + +Exit codes: + 0 seed: written at version 1, or target already holds exactly this token + dry-run: target absent, registry present; a seed would write + 1 unexpected internal error (contained; nothing is printed) + 2 precondition refused before any write: missing/wrong --confirm, not in a + warden attended envelope, identity not platform-admin (or is root), + registry absent or malformed, sender missing, sender holding zero or more + than one token (rotation overlap in progress), sender read-capable, + absence not provable, receipt path exists + 3 target exists and differs (other value, extra fields, deleted/destroyed + current version); never overwritten + 4 write failed (CAS 0 refused or bao error) + 5 verify failed: readback differs or registry changed during the run + 6 dry-run only: target already exists (a seed run compares the value) +""" +from __future__ import annotations + +import argparse +import json +import os +from pathlib import Path +import subprocess +import sys + +SOURCE = 'workloads/audit-core/senders' +TARGET = 'workloads/tenant-engine/audit-core-sender' +SENDER = 'tenant-engine' +FIELD = 'token' +CONFIRM = 'SEED tenant-engine audit-core-sender' +SCHEMA = 'platform.tenant-engine-audit-core-sender-seed.v1' + + +class Exit(Exception): + def __init__(self, code: int, reason: str): + super().__init__(reason) + self.code = code + self.reason = reason + + +def bao(args, payload=None): + # Output is always captured; nothing from bao reaches the terminal. + return subprocess.run(['bao', *args], + input=None if payload is None else json.dumps(payload).encode(), + capture_output=True, timeout=60) + + +def read_json(path, *, absent_ok, code=2): + """Return parsed ``data`` of a read, None if provably absent, else refuse.""" + result = bao(['read', '-format=json', path]) + if result.returncode == 0: + try: + return json.loads(result.stdout)['data'] + except (ValueError, KeyError, TypeError): + raise Exit(code, 'unparseable_read') from None + if absent_ok and (b'404' in result.stderr or b'No value found' in result.stderr): + return None + raise Exit(code, 'read_failed_or_absence_not_proven') + + +def live_version(meta): + """Current version number if it is live, else None (deleted/destroyed).""" + cv = meta.get('current_version') + if type(cv) is not int or cv < 1: + return None + entry = (meta.get('versions') or {}).get(str(cv)) or {} + if entry.get('destroyed') or entry.get('deletion_time'): + return None + return cv + + +def registry_token(): + raw = read_json('platform/data/' + SOURCE, absent_ok=False) + try: + version = raw['metadata']['version'] + rows = json.loads(raw['data']['senders.json']) + except (KeyError, TypeError, ValueError): + raise Exit(2, 'registry_shape_invalid') from None + if type(version) is not int or not isinstance(rows, list): + raise Exit(2, 'registry_shape_invalid') + match = [r for r in rows if isinstance(r, dict) and r.get('name') == SENDER] + if len(match) != 1: + raise Exit(2, 'sender_missing_or_duplicate') + row = match[0] + tokens = row.get('tokens') or ([row['token']] if row.get('token') else []) + if not isinstance(tokens, list) or len(tokens) != 1: + raise Exit(2, 'sender_token_count_not_one') + token = tokens[0] + if not isinstance(token, str) or not token or token != token.strip(): + raise Exit(2, 'sender_token_shape') + if row.get('may_read') is not False or row.get('may_write') is not True: + raise Exit(2, 'sender_not_write_only') + return version, token + + +def registry_version(): + meta = read_json('platform/metadata/' + SOURCE, absent_ok=True) + if meta is None or live_version(meta) is None: + raise Exit(2, 'registry_absent') + return meta['current_version'] + + +def run(args, receipt): + 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 = read_json('auth/token/lookup-self', absent_ok=False) + policies = ident.get('policies') or [] + if 'platform-admin' not in policies or 'root' in policies: + raise Exit(2, 'attended_platform_admin_required') + + receipt['registry_version_before'] = registry_version() + target_meta = read_json('platform/metadata/' + TARGET, absent_ok=True) + receipt['target_present_before'] = target_meta is not None + if args.dry_run: + receipt['value_reads'] = 0 + if target_meta is not None: + receipt['target_version_before'] = target_meta.get('current_version') + raise Exit(6, 'dry_run_target_present') + return 'dry_run_would_write' + + version, token = registry_token() + if version != receipt['registry_version_before']: + raise Exit(5, 'registry_changed_during_run') + if target_meta is not None: + if live_version(target_meta) is None: + raise Exit(3, 'target_current_version_not_live') + current = read_json('platform/data/' + TARGET, absent_ok=False) + if (current or {}).get('data') != {FIELD: token}: + raise Exit(3, 'target_differs') + receipt['target_version'] = target_meta['current_version'] + return 'already_identical' + + result = bao(['write', '-format=json', 'platform/data/' + TARGET, '-'], + payload={'options': {'cas': 0}, 'data': {FIELD: token}}) + if result.returncode != 0: + raise Exit(4, 'write_failed') + + back = read_json('platform/data/' + TARGET, absent_ok=False, code=5) + if (back or {}).get('data') != {FIELD: token} or back['metadata'].get('version') != 1: + raise Exit(5, 'readback_mismatch') + if registry_version() != version: + raise Exit(5, 'registry_changed_during_run') + receipt['target_version'] = 1 + return 'written' + + +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 'seed', + 'source': 'platform/' + SOURCE, 'target': 'platform/' + TARGET, + 'sender': SENDER, 'credential_values_emitted': False} + 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 + try: + receipt['outcome'] = run(args, receipt) + code = 0 + except Exit as exc: + receipt['outcome'] = exc.reason + code = exc.code + except Exception: + receipt['outcome'] = 'contained_internal_error' + code = 1 + 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()) diff --git a/tests/test_tenant_engine_audit_core_sender_seed.py b/tests/test_tenant_engine_audit_core_sender_seed.py new file mode 100644 index 0000000..933edbf --- /dev/null +++ b/tests/test_tenant_engine_audit_core_sender_seed.py @@ -0,0 +1,183 @@ +"""Offline tests for the tenant-engine sender seed helper against a fake ``bao``. + +The helper runs as a real subprocess with a fake ``bao`` first on PATH, so the +silence contract (empty stdout and stderr) is tested end to end. +""" +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import textwrap +import unittest + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / 'scripts/tenant_engine_audit_core_sender_seed.py' +CONFIRM = 'SEED tenant-engine audit-core-sender' +TOKEN = 'te-' + 'A1b2' * 12 +OTHER = 'op-' + 'Z9y8' * 12 +SRC = 'workloads/audit-core/senders' +DST = 'workloads/tenant-engine/audit-core-sender' + +FAKE_BAO = textwrap.dedent('''\ + #!/usr/bin/env python3 + import json, os, sys + sf = os.environ['FAKE_BAO_STATE'] + st = json.load(open(sf)) + st['calls'].append(sys.argv[1:]) + def save(): json.dump(st, open(sf, 'w')) + def out(obj): save(); print(json.dumps(obj)); sys.exit(0) + def missing(): save(); sys.stderr.write('No value found at x\\n'); sys.exit(2) + a = sys.argv[1:] + print('Success! noise', file=sys.stderr) + if a[0] == 'read': + p = a[-1] + if p == 'auth/token/lookup-self': + out({'data': st['identity']}) + mount, kind, rest = p.split('/', 2) + e = st['kv'].get(rest) + if not e: missing() + cv = len(e['versions']) + if kind == 'metadata': + out({'data': {'current_version': cv, 'versions': {str(i + 1): v['meta'] for i, v in enumerate(e['versions'])}}}) + v = e['versions'][-1] + if v['meta'].get('deletion_time'): missing() + out({'data': {'data': v['data'], 'metadata': {'version': cv}}}) + if a[0] == 'write': + p = a[-2].split('/', 2)[2] + body = json.loads(sys.stdin.read()) + st['stdin'].append(body) + if st.get('fail_write'): save(); sys.exit(2) + e = st['kv'].setdefault(p, {'versions': []}) + if body['options']['cas'] != len(e['versions']): save(); sys.exit(2) + e['versions'].append({'data': body['data'], 'meta': {}}) + if st.get('corrupt_after_write'): e['versions'][-1]['data'] = {'token': 'x'} + out({'data': {'version': len(e['versions'])}}) + save(); sys.exit(9) +''') + + +def registry(rows): + return {'versions': [{'data': {'senders.json': json.dumps(rows), 'other': 'keep'}, 'meta': {}}] * 9} + + +def sender(name, tokens, may_read=False): + return {'name': name, 'tokens': tokens, 'sources': [name], 'may_write': True, 'may_read': may_read} + + +class SeedHelper(unittest.TestCase): + def setUp(self): + self.tmp = Path(tempfile.mkdtemp()) + (self.tmp / 'bin').mkdir() + fake = self.tmp / 'bin/bao' + fake.write_text(FAKE_BAO) + fake.chmod(0o755) + self.home = self.tmp / '.warden-attended-login' / 'session' + self.home.mkdir(parents=True) + self.state = {'calls': [], 'stdin': [], 'identity': {'policies': ['default', 'platform-admin']}, + 'kv': {SRC: registry([sender('operator', [OTHER], may_read=True), + sender('tenant-engine', [TOKEN])])}} + + def run_helper(self, *extra, confirm=CONFIRM, home=None, env_extra=None, receipt=None): + sf = self.tmp / 'state.json' + sf.write_text(json.dumps(self.state)) + preexisting = receipt is not None + receipt = receipt or self.tmp / f'receipt-{len(list(self.tmp.glob("receipt-*")))}.json' + env = {'PATH': f'{self.tmp / "bin"}:/usr/bin:/bin', 'HOME': str(home or self.home), + 'FAKE_BAO_STATE': str(sf), **(env_extra or {})} + args = [sys.executable, str(SCRIPT), '--receipt', str(receipt), *extra] + if confirm is not None: + args += ['--confirm', confirm] + r = subprocess.run(args, capture_output=True, env=env, timeout=60) + self.assertEqual((r.stdout, r.stderr), (b'', b''), 'helper must be silent') + self.state = json.loads(sf.read_text()) + text = receipt.read_text() if receipt.exists() and not preexisting else '{}' + self.assertNotIn(TOKEN, text) + self.assertNotIn(OTHER, text) + for call in self.state['calls']: + self.assertNotIn(TOKEN, ' '.join(call), 'value must never be in bao argv') + if receipt.exists() and not preexisting: + self.assertEqual(receipt.stat().st_mode & 0o777, 0o600) + return r.returncode, json.loads(text) + + def test_seed_writes_cas0_and_is_idempotent(self): + code, rec = self.run_helper() + self.assertEqual((code, rec['outcome'], rec['target_version']), (0, 'written', 1)) + self.assertEqual(self.state['stdin'], [{'options': {'cas': 0}, 'data': {'token': TOKEN}}]) + self.assertEqual(self.state['kv'][DST]['versions'][0]['data'], {'token': TOKEN}) + self.assertEqual(len(self.state['kv'][SRC]['versions']), 9, 'registry untouched') + code, rec = self.run_helper() + self.assertEqual((code, rec['outcome']), (0, 'already_identical')) + self.assertEqual(len(self.state['stdin']), 1, 'no second write') + + def test_dry_run_reads_metadata_only(self): + code, rec = self.run_helper('--dry-run') + self.assertEqual((code, rec['outcome'], rec['value_reads']), (0, 'dry_run_would_write', 0)) + paths = [c[-1] for c in self.state['calls']] + self.assertTrue(all(p.startswith('platform/metadata/') or p == 'auth/token/lookup-self' for p in paths), paths) + self.assertFalse(any(c[0] == 'write' for c in self.state['calls'])) + self.state['kv'][DST] = {'versions': [{'data': {'token': TOKEN}, 'meta': {}}]} + code, rec = self.run_helper('--dry-run') + self.assertEqual((code, rec['outcome']), (6, 'dry_run_target_present')) + + def test_existing_different_target_is_refused_not_overwritten(self): + for data in ({'token': OTHER}, {'token': TOKEN, 'extra': '1'}): + self.state['kv'][DST] = {'versions': [{'data': data, 'meta': {}}]} + code, rec = self.run_helper() + self.assertEqual((code, rec['outcome']), (3, 'target_differs')) + self.assertEqual(self.state['stdin'], []) + + def test_deleted_current_version_is_refused(self): + self.state['kv'][DST] = {'versions': [{'data': {'token': TOKEN}, 'meta': {'deletion_time': '2026-10-01T00:00:00Z'}}]} + code, rec = self.run_helper() + self.assertEqual((code, rec['outcome']), (3, 'target_current_version_not_live')) + + def test_preconditions_refuse_before_any_bao_call_or_write(self): + code, rec = self.run_helper(confirm='yes') + self.assertEqual((code, rec['outcome'], self.state['calls']), (2, 'exact_confirmation_required', [])) + code, rec = self.run_helper(home=self.tmp) + self.assertEqual((code, rec['outcome']), (2, 'attended_warden_envelope_required')) + code, rec = self.run_helper(env_extra={'BAO_TOKEN': 'x'}) + self.assertEqual((code, rec['outcome']), (2, 'attended_warden_envelope_required')) + for policies in (['default'], ['platform-admin', 'root']): + self.state['identity'] = {'policies': policies} + code, rec = self.run_helper() + self.assertEqual((code, rec['outcome']), (2, 'attended_platform_admin_required')) + self.assertFalse(any(c[0] == 'write' for c in self.state['calls'])) + + def test_sender_shape_refusals(self): + cases = { + 'sender_missing_or_duplicate': [sender('operator', [OTHER], may_read=True)], + 'sender_token_count_not_one': [sender('tenant-engine', [TOKEN, TOKEN + 'b'])], + 'sender_not_write_only': [sender('tenant-engine', [TOKEN], may_read=True)], + } + for reason, rows in cases.items(): + self.state['kv'][SRC] = registry(rows) + code, rec = self.run_helper() + self.assertEqual((code, rec['outcome']), (2, reason)) + self.assertNotIn(DST, self.state['kv']) + del self.state['kv'][SRC] + code, rec = self.run_helper() + self.assertEqual((code, rec['outcome']), (2, 'registry_absent')) + + def test_write_and_verify_failures_have_distinct_codes(self): + self.state['fail_write'] = True + code, rec = self.run_helper() + self.assertEqual((code, rec['outcome']), (4, 'write_failed')) + self.state['fail_write'] = False + self.state['corrupt_after_write'] = True + code, rec = self.run_helper() + self.assertEqual((code, rec['outcome']), (5, 'readback_mismatch')) + + def test_existing_receipt_is_never_overwritten(self): + receipt = self.tmp / 'receipt-0.json' + receipt.write_text('keep') + code, _ = self.run_helper(receipt=receipt) + self.assertEqual(code, 2) + self.assertEqual(receipt.read_text(), 'keep') + self.assertEqual(self.state['calls'], []) + + +if __name__ == '__main__': + unittest.main() diff --git a/workplans/RPF-WP-0035-credential-lane-implementation.md b/workplans/RPF-WP-0035-credential-lane-implementation.md index 911be0d..3ae808f 100644 --- a/workplans/RPF-WP-0035-credential-lane-implementation.md +++ b/workplans/RPF-WP-0035-credential-lane-implementation.md @@ -46,7 +46,8 @@ id: RPF-WP-0035-T02 status: wait needs_human: true decision_id: 62aa95e4-3ccc-4867-8806-5e3db005b483 -blocking_reason: "KEY-WP-0009 finished; remaining gate is human: attended platform provisioning of the exact JWT mount/role/policy; KeyCape issuer/JWKS and service registration under KEY-WP-0009." +blocked_on: message-from:key-cape +blocking_reason: "SECRETS-WP-0007-T04 done 2026-09-27, so that gate is open. Real blocker: live KeyCape registration of client secrets-engine-openbao and its protected client-credential custody (contract: separate approved custody action; no owning task id yet, asked key-cape 2026-10-03). The design requires both before any write; then a reviewed child and the attended founder apply." priority: high state_hub_task_id: "e0c82ea9-05a5-537e-8fbf-67534263572e" ``` @@ -72,7 +73,7 @@ contract, and rollback/cleanup receipts contain no secret material. 2026-10-03: human gate routed to hub decision 62aa95e4-3ccc-4867-8806-5e3db005b483 (D1: Attended OpenBao provisioning window: one session clears the pending credential lanes) by CUST-WP-0075; resolving that decision raises C-40 here. -2026-10-03 correction (CUST-WP-0075, informed-decision sitting draft `55fb078`): the D1 routing was wrong. First provision and delivery already ran on 2026-09-11, with seed and delivery receipts, and the review packet says not to repeat them. The task is not a provisioning-window act. It was taken off decision D1, and its blocking_reason now names what actually remains. railiance-platform should re-qualify it further if the receiver rollout has since happened. +2026-10-03 (CUST-WP-0075): gate check. SECRETS-WP-0007-T04 is done (2026-09-27), so the gate named in the design is open. It is not the last one: the design says to confirm the exact live client registration and protected client-side custody before writing, and positive-login proof needs a real KeyCape service JWT. KeyCape's contract (`openbao-service-auth-contract.md`) and KEY-WP-0009 claim source registration only; the live registration of `secrets-engine-openbao` and its client-credential custody are "a separate approved custody action" that no workplan task owns yet (SECRETS-WP-0008-T06 waits on this task). Re-qualified with `blocked_on: message-from:key-cape`; key-cape was asked for the owning task id, which will replace it as `depends_on`. The design stays proposed and no child command was written. Not approvable in D1 yet. ## Implement the platform operator-write CCR contract and Fluid lane @@ -439,13 +440,17 @@ into this client-identity grant. id: RPF-WP-0035-T08 status: wait needs_human: true -blocking_reason: "Provisioned and delivered 2026-09-11 (CCR-2026-0021/0022 applied); do not repeat. Remaining: native delivery/receiver-ingestion acceptance (receiver image must carry evidence_kind; Approval Engine namespace is its owner prerequisite), archive readback, and revocation of the interim audit bearer tokens." +blocking_reason: "Provisioned and delivered 2026-09-11 (CCR-2026-0021/0022 applied); do not repeat. Receiver rollout with evidence_kind and native producer acceptance passed 2026-09-11. Remaining human acceptance: independent archive readback and revocation of the interim audit bearer tokens." priority: high assignee: railiance-platform intervention_note: null state_hub_task_id: "0ef52c26-2669-5cd1-8149-b1f6fcdda6cb" ``` +2026-10-03 correction (CUST-WP-0075, informed-decision sitting draft `55fb078`): the D1 routing was wrong. First provision and delivery already ran on 2026-09-11, with seed and delivery receipts, and the review packet says not to repeat them. The task is not a provisioning-window act. It was taken off decision D1, and its blocking_reason now names what actually remains. railiance-platform should re-qualify it further if the receiver rollout has since happened. + +2026-10-03 (CUST-WP-0075): re-qualified. The receiver rollout already happened: Audit Core c82e0442de0f carries evidence_kind and native producer accepted/duplicate and scope/read-refusal checks passed 2026-09-11 10:42 UTC (`docs/credential-lane-designs/factory-audit-senders-review.md`, `factory-native-acceptance.md`). Remaining: independent archive readback and audit bearer-revocation acceptance. Still not a provisioning-window act. + Consume AUDIT-WP-0009-T09/T11's exact declarations for approval-engine and informed-decision: tenant:platform, write-only, load-bearing, redact. Source preparation supplies two approved CCRs, independent exact-path policies/ESO