Fix founder publication login handoff and session reporting
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s

Assistant: codex
Assistant-Model: gpt-6.1-sol
Assistant-Session: 01a1abf1-7e95-7141-87c9-ee78332bf790
This commit is contained in:
tegwick 2026-11-06 01:42:42 +01:00
parent 412787515c
commit 473187c114
7 changed files with 295 additions and 17 deletions

View file

@ -0,0 +1,30 @@
{
"schema": "railiance.vergabe-pilot-provider-recovery.v1",
"run_id": "20261105T233543Z-89bfbe04dece",
"status": "failed",
"provider_publication_verified": false,
"decryption_verified": false,
"restore_verified": false,
"application_changed": false,
"stage": "owner_custody",
"failure": "contained_owner_login_required",
"elapsed_seconds": 0.01,
"warden_exit_code": 11,
"receipt_file": "/home/worsch/.local/state/vergabe-pilot-recovery/20261105T233543Z-89bfbe04dece/provider-receipt-48cc9fb0018d.json",
"session_cleanup_confirmed": false,
"evidence_kind": "user_reported_terminal_output",
"reported_on": "2026-11-06",
"source_revision": "412787515c5ad662b8014347f9eb1bb3b8b8da0f",
"workplan": "RAPPS-WP-0014",
"task": "RAPPS-WP-0014-T03",
"review": {
"founder_login_succeeded": true,
"session_revoked_user_reported": true,
"provider_custody_reads_reached": false,
"provider_calls_reached": false,
"session_cleanup_false_is_publisher_reporting_defect": true,
"original_guard_did_not_identify_which_condition_failed": true,
"source_mismatch": "Publisher rejects OPENBAO_TOKEN, but the wrapped login only clears BAO_TOKEN and VAULT_TOKEN; an inherited alias reproduces this failure in the real Warden envelope with an offline fake Bao client.",
"fix": "Clear all three token aliases before the fresh owner login; retain explicit isolated-home/tunnel/policy checks and safe per-condition diagnostics. Keep session lifecycle reporting distinct from child exit status."
}
}

View file

@ -5,6 +5,15 @@ verifies ciphertext SHA256 `49152883…6bef76f`, 182521 bytes, for run
`20261105T233543Z-89bfbe04dece`. The app remains running. The private encrypted
kit is already on the founder's workstation. [Exact scope and source pins](plan.json).
First native attempt: [the founder return](../../evidence/2026-11-06-vergabe-provider-custody-failure-return.json)
reports `contained_owner_login_required` before credential reads/provider calls.
Warden logged in and reported session revocation; the publisher's old aggregate
cleanup flag was misleading. Revision 2 clears all three token aliases before
the fresh login and verifies Warden's private session-directory context. An
offline test through the installed Warden envelope reproduces the inherited
alias refusal and passes with the corrected preparation. Retry the same command
below; the previous session was revoked. Provider scope and ciphertext are unchanged.
## Founder workstation command
```bash
@ -46,6 +55,14 @@ boundary policies and ambient token variables refuse. The OpenBao tunnel remains
`http://127.0.0.1:18200`, as in the existing owner wrapper. No fallback identity
or changed control is introduced.
Before launching the wrapper, the publisher clears `BAO_TOKEN`, `VAULT_TOKEN`
and `OPENBAO_TOKEN` and pins both address aliases to the tunnel. Warden's wrapper
already clears the first two; leaving the third inherited conflicted with the
publisher's own guard. The child verifies directory ownership/permissions and
the `.warden-attended-login/session-*` HOME shape, without inspecting the token
helper. Its receipt includes only boolean handoff checks. Self-lookup uses the
owner's `auth/token/lookup-self` API form and combines token/identity policies.
## Exact provider change
One object in the existing `r01-platform-pg` backup folder:
@ -84,6 +101,13 @@ the same deterministic object name. Warden output/cleanup errors remain failures
even if data verification finished. Inspect only the allowed receipt, never
Warden's captured output or token helper.
For failed children, exit 11 alone cannot distinguish confirmed revocation from
an unconfirmed revocation. The new receipt records `null` for that uncertainty,
with storage cleanup separately reported from Warden's documented exit contract;
13 means revocation unconfirmed and 14 means private-storage cleanup failed.
Only exit 0 confirms the entire envelope. The original failure receipt is kept
unchanged as historical evidence, alongside its review of the reporting defect.
A new `provider-recovery-*` directory inside the private workstation kit retains
the retrieved ciphertext, decrypted ZIP and unpacked recovery point for the next
isolated restore. The directory is 0700 and files 0600, outside Git; do not open
@ -97,10 +121,13 @@ No second application pause or recapture is needed for this point.
## Validation
Eleven synthetic checks cover real age encrypt/decrypt and complete packet/media/
Fifteen publisher checks cover real age encrypt/decrypt and complete packet/media/
SQLite verification with mocked custody/provider; separate upload/readback
credentials; identical-object retry; different-object preservation; wrong escrow,
quota and tampered-download refusal; credential-safe silent failure; ambient
token refusal; pinned owner helpers; foreign-origin denial and bounded readback.
Native provider publication/decryption has not executed until its founder receipt
returns.
The additional cases test all token aliases, API/effective-policy handling,
separate session outcomes and the installed Warden envelope using an offline
fake Bao client. They issue no native session and inspect no real helper or
credential. All 45 pilot helper checks pass. Native provider publication and
decryption remain unverified until a successful founder receipt returns.

View file

@ -6,7 +6,7 @@
"run_id": "20261105T233543Z-89bfbe04dece",
"ciphertext_sha256": "4915288384e3ba8a87f1427b8e10106ee7241c04184adb97f439e57846bef76f",
"ciphertext_bytes": 182521,
"publisher_source_sha256": "845294c574692333b35e627f5d3194e6334136f14f4f699e27a680f9cd4604db",
"publisher_source_sha256": "8815272ed09ffce91e2a1551c646285d8aea844f318fc188c986d15f4093f807",
"platform_helper_pins": {
"openbao-attended-exec.py": "53fb5fb38ad17c4b06d7969a4d8968d976c8ebc8f1abfeb9a191aa43dc54a5c3",
"migrate_nextcloud_backup_account.py": "d3285f403f3c947f75c3288d989152e59ab9a8f5542bcf901bd0a06b0f3e050c",
@ -53,10 +53,29 @@
"recovered_data": "Private provider-recovery-* directory inside the existing workstation kit, outside Git, retained for the isolated restore rehearsal",
"failure_handling": "Safe receipt with phase flags; different object bytes refuse without overwrite. Retry reads the deterministic object name. Warden exit and cleanup confirmation are recorded separately from data verification.",
"validation": {
"publisher_cases": 11,
"publisher_cases": 15,
"synthetic_only": true,
"actual_age_round_trip": true,
"provider_and_custody_mocked": true,
"native_provider_publication_executed": false
"native_provider_publication_executed": false,
"total_pilot_cases": 45,
"real_installed_warden_offline_fixture": true,
"synthetic_bao_only": true,
"native_login_used_by_tests": false
},
"previous_publisher_source_sha256": "845294c574692333b35e627f5d3194e6334136f14f4f699e27a680f9cd4604db",
"revision": "r2-owner-handoff",
"handoff_fix": {
"clear_all_token_aliases_before_login": [
"BAO_TOKEN",
"VAULT_TOKEN",
"OPENBAO_TOKEN"
],
"isolation": "Verify expected private Warden session HOME by directory metadata only; never inspect token-helper contents",
"safe_handoff_booleans_in_receipt": true,
"lookup_api": "auth/token/lookup-self",
"effective_policies": "Union token and identity policies; root/agent boundary still refuse",
"nonzero_child_exit_does_not_imply_revocation_failure": true,
"same_provider_scope": true
}
}

View file

@ -160,6 +160,66 @@ class PublisherTests(unittest.TestCase):
class BoundaryTests(unittest.TestCase):
def test_owner_environment_discards_all_token_aliases_before_fresh_login(self):
environment = {name: "synthetic" for name in publish.TOKEN_ENV}
environment.update(HOME="/synthetic", PATH="/usr/bin", BAO_ADDR="wrong", VAULT_ADDR="wrong")
result = publish.owner_login_environment(environment)
self.assertFalse(set(result) & set(publish.TOKEN_ENV))
self.assertEqual(result["BAO_ADDR"], publish.TUNNEL)
self.assertEqual(result["VAULT_ADDR"], publish.TUNNEL)
self.assertEqual(result["HOME"], "/synthetic")
self.assertTrue(set(environment) & set(publish.TOKEN_ENV))
def test_real_warden_envelope_with_offline_synthetic_login(self):
executable = shutil.which("warden")
if not executable:
self.skipTest("installed Warden is required for its real envelope fixture")
first = Path(executable).read_text().splitlines()[0]
if not first.startswith("#!/") or " " in first:
self.skipTest("Warden Python interpreter could not be identified")
result = subprocess.run([first[2:], str(Path(__file__).with_name("vergabe_pilot_warden_handoff_fixture.py"))],
capture_output=True, timeout=30)
self.assertEqual(result.returncode, 0, "offline Warden handoff fixture failed")
evidence = json.loads(result.stdout)
self.assertTrue(evidence["sanitized_handoff_passed"])
self.assertFalse(evidence["real_credentials_used"])
def test_failed_child_exit_does_not_prove_revocation_failure(self):
outcome = publish.session_outcome(11)
self.assertIsNone(outcome["session_cleanup_confirmed"])
self.assertIsNone(outcome["session_revocation_confirmed"])
self.assertTrue(outcome["session_storage_cleanup_confirmed"])
self.assertFalse(publish.session_outcome(13)["session_revocation_confirmed"])
self.assertFalse(publish.session_outcome(14)["session_storage_cleanup_confirmed"])
self.assertTrue(publish.session_outcome(0)["session_cleanup_confirmed"])
def test_owner_custody_uses_lookup_api_and_effective_identity_policies(self):
from types import SimpleNamespace
calls = []
def read(args):
calls.append(args)
if args[2] == "auth/token/lookup-self":
return {"data": {"policies": ["default", "platform-admin"],
"identity_policies": ["operator-custody"]}}
if args[2] == publish.LANE:
return {"data": {"metadata": {"version": 1}, "data": {
"NC_WEBDAV_TOKEN": "synthetic-upload",
"NC_WEBDAV_URL": publish.HOST + "/public.php/dav/filesdrop/synthetic-upload",
"AGE_PRIVATE_KEY": "synthetic-not-a-key"}}}
if args[2] == publish.OPERATOR:
return {"data": {"data": {"BACKUP_USERNAME": "Backup", "BACKUP_PASSWORD": "synthetic"}}}
raise AssertionError("unexpected custody read")
with tempfile.TemporaryDirectory(prefix="rapps-owner-context-") as temporary:
root = Path(temporary) / ".warden-attended-login"
root.mkdir(mode=0o700)
home = root / "session-fixture"
home.mkdir(mode=0o700)
with patch.dict(os.environ, {"HOME": str(home), "WARDEN_ROUTING_CATALOG": "synthetic",
"BAO_ADDR": publish.TUNNEL, "VAULT_ADDR": publish.TUNNEL}, clear=True):
result = publish.custody(SimpleNamespace(bao=read, data=lambda value: value))
self.assertEqual(result["kv_version"], 1)
self.assertEqual([c[2] for c in calls], ["auth/token/lookup-self", publish.LANE, publish.OPERATOR])
def provider(self):
from types import SimpleNamespace
return publish.Provider(SimpleNamespace(HOST=publish.HOST, LANE=publish.LANE,
@ -189,8 +249,9 @@ class BoundaryTests(unittest.TestCase):
def test_ambient_token_refuses_without_a_secret_read(self):
from types import SimpleNamespace
with patch.dict(os.environ, {"WARDEN_ROUTING_CATALOG": "synthetic", "BAO_ADDR": "http://127.0.0.1:18200",
"BAO_TOKEN": "synthetic-not-real"}), self.assertRaises(stores.Refused):
with patch.dict(os.environ, {"WARDEN_ROUTING_CATALOG": "synthetic", "BAO_ADDR": publish.TUNNEL,
"VAULT_ADDR": publish.TUNNEL, "BAO_TOKEN": "synthetic-not-real"}), \
self.assertRaisesRegex(stores.Refused, "ambient_owner_token_refused"):
publish.custody(SimpleNamespace())
def test_owner_helper_pins_validate_without_native_access(self):

View file

@ -0,0 +1,77 @@
#!/usr/bin/env python3
"""Exercise installed Warden's real envelope with a fake, offline Bao client.
Only synthetic files beneath a temporary HOME. No real token helper, login,
OpenBao, provider, backup or operator credential is inspected or used.
"""
import json
import os
import sys
import tempfile
from pathlib import Path
from unittest.mock import patch
from warden.proxy import AttendedLoginError, ResolvedFetch, proxy_attended_login_exec
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tools"))
import vergabe_pilot_publish as publisher
def main():
with tempfile.TemporaryDirectory(prefix="rapps-warden-offline-", dir="/tmp") as temporary:
root = Path(temporary)
caller = root / "caller"
caller.mkdir(mode=0o700)
client = root / "bao"
client.write_text("#!" + sys.executable + "\n" + '''
import os,sys
from pathlib import Path
if sys.argv[1] == 'login':
helper=Path.home()/'.vault-token'
helper.write_text('SYNTHETIC-NO-AUTH')
helper.chmod(0o600)
elif sys.argv[1:] != ['token','revoke','-self']:
raise SystemExit(2)
''')
client.chmod(0o700)
proof = root / "handoff-proof.json"
code = '''
import json,sys
from pathlib import Path
sys.path.insert(0,sys.argv[1])
import vergabe_pilot_publish as p
try:
p.require_owner_handoff()
except p.Refused:
raise SystemExit(1)
Path(sys.argv[2]).write_text(json.dumps(p.owner_handoff_checks()))
'''
child = [sys.executable, "-c", code, str(Path(publisher.__file__).parent), str(proof)]
environment = {"HOME": str(caller), "PATH": os.environ.get("PATH", "/usr/bin:/bin"),
"WARDEN_ROUTING_CATALOG": str(root / "synthetic-catalog"),
"BAO_ADDR": publisher.TUNNEL, "VAULT_ADDR": publisher.TUNNEL,
"BAO_TOKEN": "synthetic-parent", "VAULT_TOKEN": "synthetic-parent",
"OPENBAO_TOKEN": "synthetic-inherited-alias"}
command = ResolvedFetch(argv=[str(client), "login"])
with patch.dict(os.environ, environment, clear=True):
try:
proxy_attended_login_exec(command, child_argv=child)
except AttendedLoginError as error:
assert error.exit_code == 11 and error.outcome == "child_failed"
else:
raise AssertionError("unsanitized alias must refuse before custody")
assert not proof.exists()
assert not (caller / ".warden-attended-login").exists()
with patch.dict(os.environ, publisher.owner_login_environment(environment), clear=True):
assert proxy_attended_login_exec(command, child_argv=child) == 0
assert all(json.loads(proof.read_text()).values())
assert not (caller / ".warden-attended-login").exists()
print(json.dumps({"status": "passed", "installed_warden_envelope": True,
"unsanitized_alias_refused": True, "sanitized_handoff_passed": True,
"synthetic_session_storage_removed": True, "native_login": False,
"provider_access": False, "real_credentials_used": False}))
if __name__ == "__main__":
main()

View file

@ -39,6 +39,8 @@ CIPHER_SHA256 = "4915288384e3ba8a87f1427b8e10106ee7241c04184adb97f439e57846bef76
CIPHER_BYTES = 182521
LANE = "platform/data/workloads/railiance/backup/offsite-lane"
OPERATOR = "operators/data/nextcloud/backup"
TUNNEL = "http://127.0.0.1:18200"
TOKEN_ENV = ("BAO_TOKEN", "VAULT_TOKEN", "OPENBAO_TOKEN")
PINS = {
"openbao-attended-exec.py": "53fb5fb38ad17c4b06d7969a4d8968d976c8ebc8f1abfeb9a191aa43dc54a5c3",
"migrate_nextcloud_backup_account.py": "d3285f403f3c947f75c3288d989152e59ab9a8f5542bcf901bd0a06b0f3e050c",
@ -71,20 +73,52 @@ def source(directory):
return directory / OBJECT
def owner_login_environment(environ=None):
env = dict(os.environ if environ is None else environ)
for key in TOKEN_ENV:
env.pop(key, None)
env["BAO_ADDR"] = env["VAULT_ADDR"] = TUNNEL
return env
def owner_handoff_checks():
home = Path.home()
shape = home.parent.name == ".warden-attended-login" and home.name.startswith("session-")
private = False
if shape:
try:
private_directory(home)
private_directory(home.parent)
private = True
except (OSError, Refused):
pass
return {"routing_marker_present": bool(os.environ.get("WARDEN_ROUTING_CATALOG")),
"operator_tunnel_matches": os.environ.get("BAO_ADDR") == os.environ.get("VAULT_ADDR") == TUNNEL,
"no_ambient_token_variables": not any(os.environ.get(k) for k in TOKEN_ENV),
"isolated_home_shape": shape, "isolated_home_private": private}
def require_owner_handoff():
checks = owner_handoff_checks()
for key, reason in [("routing_marker_present", "owner_routing_marker_missing"),
("operator_tunnel_matches", "owner_tunnel_required"),
("no_ambient_token_variables", "ambient_owner_token_refused"),
("isolated_home_shape", "isolated_owner_home_required"),
("isolated_home_private", "private_owner_home_required")]:
require(checks[key], reason)
def custody(helper):
# Reuse the owner's contained OIDC session, never a token environment/file
# handoff. Warden revokes and removes its private helper after the child.
require(os.environ.get("WARDEN_ROUTING_CATALOG")
and os.environ.get("BAO_ADDR") == "http://127.0.0.1:18200"
and not any(os.environ.get(k) for k in ("BAO_TOKEN", "VAULT_TOKEN", "OPENBAO_TOKEN")),
"contained_owner_login_required")
require_owner_handoff()
def read(args, reason):
try:
return helper.data(helper.bao(args))
except Exception:
raise Refused(reason) from None
identity = read(["token", "lookup", "-format=json"], "owner_identity_lookup_failed")["data"]
policies = set(identity.get("policies", []))
identity = read(["read", "-format=json", "auth/token/lookup-self"], "owner_identity_lookup_failed")["data"]
policies = set(identity.get("policies") or []) | set(identity.get("identity_policies") or [])
require({"platform-admin", "operator-custody"} <= policies
and not policies & {"root", "agent-high-risk-boundary"}, "owner_recovery_identity_required")
lane = read(["read", "-format=json", LANE], "offsite_custody_read_failed")["data"]
@ -269,6 +303,7 @@ def child(directory, output):
"stage": "owner_custody"}
try:
helper = owner_helpers()
receipt["owner_handoff_checks"] = owner_handoff_checks()
publish(directory, custody(helper), Provider(helper), receipt)
except BaseException as error:
receipt["failure"] = str(error) if isinstance(error, Refused) else "provider_recovery_failed"
@ -281,6 +316,14 @@ def child(directory, output):
return 0 if receipt["status"] == "published_read_back_and_decrypted" else 1
def session_outcome(exit_code):
# Warden 11/12 can mean a revoked or revocation-unconfirmed failed child.
# Only 0 confirms the whole envelope; 14 overrides any earlier phase.
return {"session_cleanup_confirmed": True if exit_code == 0 else False if exit_code in {13, 14} else None,
"session_revocation_confirmed": True if exit_code == 0 else False if exit_code == 13 else None,
"session_storage_cleanup_confirmed": True if exit_code in {0, 11, 12, 13} else False if exit_code == 14 else None}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--execute", action="store_true")
@ -310,14 +353,14 @@ def main():
command = [sys.executable, str(PLATFORM / "scripts/openbao-attended-exec.py"), "--",
sys.executable, str(Path(__file__).resolve()), "--contained", "--directory", str(directory),
"--receipt", str(output)]
result = subprocess.run(command, check=False)
result = subprocess.run(command, check=False, env=owner_login_environment())
if output.exists():
export.regular(output, os.geteuid())
receipt = json.loads(output.read_text())
else:
receipt = {"status": "owner_login_or_child_not_completed", "provider_outcome": "unconfirmed"}
receipt.update(warden_exit_code=result.returncode, receipt_file=str(output),
session_cleanup_confirmed=result.returncode == 0)
**session_outcome(result.returncode))
flags = os.O_WRONLY | os.O_NOFOLLOW | (os.O_TRUNC if output.exists() else os.O_CREAT | os.O_EXCL)
fd = os.open(output, flags, 0o600)
with os.fdopen(fd, "w") as stream:

View file

@ -196,7 +196,7 @@ id: RAPPS-WP-0014-T03
status: wait
needs_human: true
decision_id: d06307e5-2737-461a-a2f4-a12c3b492a9a
intervention_note: "Matched populated capture, resume and encrypted workstation copy pass. Both accounts work after restart, with an Übersicht omission under UI review. Next: founder-run tools/vergabe_pilot_publish.py --execute for the prepared contained owner publication/readback/decryption ceremony. Isolated restore, access/rollback and operating ownership remain open."
intervention_note: "Capture/restart and encrypted workstation copy pass. First provider attempt logged in but the publisher handoff guard refused before credential/provider access; Warden reported revocation. Environment preparation and lifecycle reporting fixed and tested through the installed Warden envelope offline. Retry the same publisher --execute command with fresh founder login. Provider evidence, isolated restore, access/rollback and operating ownership remain open."
priority: high
assignee: the-custodian
blocking_reason: "Matched populated capture, restart and encrypted workstation copy pass with overview UI follow-up. Await provider publication/independent readback, escrow decryption, isolated restored workflow, access checks, compatible rollback and operating ownership."
@ -451,6 +451,27 @@ pass, including real age roundtrip with mocked provider/custody. Native executio
awaits the founder; Warden cleanup is distinct from data verification. T03 stays
waiting for provider evidence and the remaining restore/rollback/access/owner gates.
2026-11-06 first provider attempt: Warden login succeeded, but the publisher
returned `contained_owner_login_required` at `owner_custody` (0.01 seconds,
Warden exit 11). Source ordering places this before token metadata, credential
reads or provider calls. Warden's terminal message confirms revocation. The
publisher's old `session_cleanup_confirmed: false` conflated a failed child with
cleanup failure; preserve the original receipt and this correction:
`docs/evidence/2026-11-06-vergabe-provider-custody-failure-return.json`.
Revision 2 clears BAO_TOKEN, VAULT_TOKEN and OPENBAO_TOKEN before the existing
wrapper, checks private Warden HOME-directory metadata (no helper inspection),
records boolean handoff diagnostics, uses the owner's API self-lookup form and
combines effective token/identity policies. Exit 11 no longer claims revocation
failure from its code alone. The first receipt did not identify which guard
condition failed; the source mismatch for the third inherited token alias is
reproduced by an offline test through the installed real Warden envelope with a
fake Bao client. The normalized handoff and session cleanup pass that test.
Fifteen publisher / 45 total pilot checks pass; no native auth or provider call
occurs in those tests. Retry the same reviewed provider command; the earlier
go persists, while fresh login is needed because that session was revoked. No
provider scope, role, policy, credential grant or live app change is introduced.
## Validate synthetic sizing against the exact release and shared contention