diff --git a/docs/credential-lane-designs/application-backup-pair-custody-2026-10-08.md b/docs/credential-lane-designs/application-backup-pair-custody-2026-10-08.md new file mode 100644 index 0000000..75d2158 --- /dev/null +++ b/docs/credential-lane-designs/application-backup-pair-custody-2026-10-08.md @@ -0,0 +1,37 @@ +# Backup publisher pair custody — preview, 2026-10-08 + +RPF-WP-0053-T06. The node reader in `scripts/application_backup_credentials.py` +can deliver an already stored pair. It cannot create one. Warden's paste-once +desk writes one field per PUT and would drop the other half of +`AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY`. + +`scripts/application_backup_pair_custody.py` is the prepared front door for +both draft records: + +| Application | CCR | KV path | +| --- | --- | --- | +| coulomb-social | CCR-2026-0036 | platform/workloads/coulomb-social/backup-primary | +| email-connect | CCR-2026-0037 | platform/workloads/email-connect/backup-primary | + +Default command: + +```bash +python3 scripts/application_backup_pair_custody.py --application email-connect +``` + +The preview reads the draft CCR and prints field names, CAS 0 and one intended +write. `writer_admitted` is false. There is no stdin, provider call, OpenBao +call, key generation, file write or timer change. + +`provision()` is the future attended shape, covered by synthetic tests only: + +- writer token policies must be exactly `backup-pair-writer`, with no default + policy and no `workload-kv-read-*-backup-primary` reader policy +- TTL at most 5 minutes and at most 2 uses, then revoke +- metadata absence is required; an existing version is not overwritten +- one HTTP-style body carries `cas: 0` and both fields +- the receipt keeps version and request id, never the values + +`WRITER_ADMITTED` stays false, so that function refuses before any transport +call. No Scaleway IAM application or key was created. CCR-2026-0036 and +CCR-2026-0037 stay draft. EMAIL-WP-0007-T03/T04 stay waiting on this admission. diff --git a/scripts/application_backup_pair_custody.py b/scripts/application_backup_pair_custody.py new file mode 100644 index 0000000..7657210 --- /dev/null +++ b/scripts/application_backup_pair_custody.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 +"""Preview custody for the two backup publisher pairs. + +A later attended act may store both AWS fields in one CAS=0 write. This +command does not generate provider keys, contact OpenBao, or perform that +write. The delivery reader in application_backup_credentials.py cannot +create or rotate these values. +""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +FIELDS = ("AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY") +WRITER_POLICY = "backup-pair-writer" +WRITER_ADMITTED = False +PROFILES = { + "coulomb-social": "CCR-2026-0036", + "email-connect": "CCR-2026-0037", +} +VALUE = re.compile(r"[!-~]{16,128}\Z") + + +class CustodyError(Exception): + """Fixed diagnostic. It never carries key material.""" + + +def guard(condition, code): + if not condition: + raise CustodyError(code) + + +def kv_path(application): + guard(application in PROFILES, "unknown_application") + return f"platform/workloads/{application}/backup-primary" + + +def ccr_path(application): + prefix = PROFILES[application] + return ROOT / "credential-change-requests" / f"{prefix}-{application}-backup-primary.yaml" + + +def preview(application): + raw = ccr_path(application).read_text(encoding="utf-8") + document = json.loads(raw) + guard(document.get("id") == PROFILES[application], "ccr_mismatch") + guard(document.get("openbao", {}).get("fields") == list(FIELDS), "field_mismatch") + guard(document.get("openbao", {}).get("kv_path") == kv_path(application), "path_mismatch") + return { + "status": "preview", + "application": application, + "ccr": document["id"], + "ccr_status": document.get("status"), + "kv_path": kv_path(application), + "fields": list(FIELDS), + "cas": 0, + "writes": 1, + "writer_policy": WRITER_POLICY, + "writer_admitted": WRITER_ADMITTED, + "provider_keys": "not_created", + "openbao_write": False, + "effects": [], + } + + +def validate_pair(values): + guard(isinstance(values, dict) and set(values) == set(FIELDS), "exact_pair_required") + stored = [] + for name in FIELDS: + value = values[name] + guard(isinstance(value, str) and VALUE.fullmatch(value), "invalid_pair_value") + stored.append(value) + guard(stored[0] != stored[1], "pair_values_must_differ") + return {name: values[name] for name in FIELDS} + + +def assert_writer(identity, application): + """Refuse the delivery reader, a generic grant, and any unreviewed writer.""" + guard(WRITER_ADMITTED, "writer_identity_unreviewed") + policies = identity.get("policies") + reader = f"workload-kv-read-{application}-backup-primary" + guard( + policies == [WRITER_POLICY] + and identity.get("identity_policies") == [] + and reader not in policies + and "default" not in policies, + "writer_identity_unreviewed", + ) + ttl = identity.get("ttl") + uses = identity.get("num_uses") + guard(type(ttl) is int and 0 < ttl <= 300, "writer_ttl_unbounded") + guard(type(uses) is int and 0 < uses <= 2, "writer_uses_unbounded") + + +def provision(application, values, identity, transport): + """One metadata check and one CAS=0 write. Tests inject transport. + + Live use stays disconnected while WRITER_ADMITTED is false. + """ + path = kv_path(application) + pair = validate_pair(values) + assert_writer(identity, application) + metadata = transport.metadata(f"platform/metadata/workloads/{application}/backup-primary") + guard(metadata is None, "existing_custody_requires_rotation_review") + written = transport.put_cas0( + f"platform/data/workloads/{application}/backup-primary", + {"options": {"cas": 0}, "data": pair}, + ) + try: + guard(written.get("version") == 1 and set(written) <= {"version", "request_id"}, "unexpected_version") + return { + "application": application, + "kv_path": path, + "cas": 0, + "kv_version": 1, + "field_names": list(FIELDS), + "request_id": written.get("request_id"), + "secret_material": False, + } + finally: + transport.revoke() + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--application", required=True, choices=sorted(PROFILES)) + args = parser.parse_args(argv) + json.dump(preview(args.application), sys.stdout, indent=2) + sys.stdout.write("\n") + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except CustodyError as exc: + print(str(exc), file=sys.stderr) + raise SystemExit(2) from None diff --git a/tests/test_application_backup_pair_custody.py b/tests/test_application_backup_pair_custody.py new file mode 100644 index 0000000..f4ee2ad --- /dev/null +++ b/tests/test_application_backup_pair_custody.py @@ -0,0 +1,115 @@ +"""Pair custody stays preview-only until a writer identity is admitted.""" + +import importlib.util +import json +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +spec = importlib.util.spec_from_file_location( + "pair_custody", ROOT / "scripts/application_backup_pair_custody.py" +) +custody = importlib.util.module_from_spec(spec) +spec.loader.exec_module(custody) + +PAIR = { + "AWS_ACCESS_KEY_ID": "SYNTHETICACCESSKEY123", + "AWS_SECRET_ACCESS_KEY": "SYNTHETICSECRETKEY4567", +} + + +class Transport: + def __init__(self): + self.calls = [] + self.metadata_result = None + self.version = 1 + + def metadata(self, path): + self.calls.append(("metadata", path)) + return self.metadata_result + + def put_cas0(self, path, body): + self.calls.append(("put", path, body["options"]["cas"], tuple(body["data"]))) + return {"version": self.version, "request_id": "synthetic-request"} + + def revoke(self): + self.calls.append(("revoke",)) + + +def identity(): + return { + "policies": [custody.WRITER_POLICY], + "identity_policies": [], + "ttl": 300, + "num_uses": 2, + } + + +@pytest.mark.parametrize("application", sorted(custody.PROFILES)) +def test_preview_reads_draft_contract_and_writes_nothing(application, capsys, monkeypatch): + monkeypatch.setattr(custody, "provision", lambda *_: pytest.fail("preview provisioned")) + assert custody.main(["--application", application]) == 0 + receipt = json.loads(capsys.readouterr().out) + assert receipt["status"] == "preview" + assert receipt["ccr_status"] == "draft" + assert receipt["writer_admitted"] is False + assert receipt["openbao_write"] is False + assert receipt["provider_keys"] == "not_created" + assert receipt["effects"] == [] + assert receipt["fields"] == list(custody.FIELDS) + rendered = json.dumps(receipt) + assert "SYNTHETIC" not in rendered + assert "AKIA" not in rendered + + +def test_unreviewed_writer_makes_no_transport_call(): + transport = Transport() + with pytest.raises(custody.CustodyError, match="writer_identity_unreviewed"): + custody.provision("email-connect", PAIR, identity(), transport) + assert transport.calls == [] + + +def test_admitted_writer_uses_one_cas0_write_and_revokes(monkeypatch): + monkeypatch.setattr(custody, "WRITER_ADMITTED", True) + transport = Transport() + receipt = custody.provision("email-connect", dict(PAIR), identity(), transport) + assert receipt["kv_version"] == 1 + assert receipt["secret_material"] is False + assert PAIR["AWS_SECRET_ACCESS_KEY"] not in json.dumps(receipt) + assert transport.calls == [ + ("metadata", "platform/metadata/workloads/email-connect/backup-primary"), + ( + "put", + "platform/data/workloads/email-connect/backup-primary", + 0, + custody.FIELDS, + ), + ("revoke",), + ] + + +def test_reader_policy_and_existing_version_are_refused(monkeypatch): + monkeypatch.setattr(custody, "WRITER_ADMITTED", True) + reader = dict(identity(), policies=["workload-kv-read-email-connect-backup-primary"]) + with pytest.raises(custody.CustodyError, match="writer_identity_unreviewed"): + custody.provision("email-connect", PAIR, reader, Transport()) + transport = Transport() + transport.metadata_result = {"current_version": 1} + with pytest.raises(custody.CustodyError, match="existing_custody_requires_rotation_review"): + custody.provision("email-connect", PAIR, identity(), transport) + assert [call[0] for call in transport.calls] == ["metadata"] + + +@pytest.mark.parametrize( + "values", + [ + {"AWS_ACCESS_KEY_ID": PAIR["AWS_ACCESS_KEY_ID"]}, + dict(PAIR, AWS_SECRET_ACCESS_KEY="short"), + dict(PAIR, AWS_ACCESS_KEY_ID=PAIR["AWS_SECRET_ACCESS_KEY"]), + dict(PAIR, EXTRA="SYNTHETICEXTRAVALUE1"), + ], +) +def test_partial_or_embedded_pairs_are_refused(values): + with pytest.raises(custody.CustodyError): + custody.validate_pair(values) diff --git a/workplans/RPF-WP-0053-coulomb-paired-backup-admission.md b/workplans/RPF-WP-0053-coulomb-paired-backup-admission.md index 47bdd00..a8ab2d3 100644 --- a/workplans/RPF-WP-0053-coulomb-paired-backup-admission.md +++ b/workplans/RPF-WP-0053-coulomb-paired-backup-admission.md @@ -173,3 +173,66 @@ never auto-enable a recovered receiver or resend an unknown outcome. Exact first pause/upload/schedule, actual offsite/recovery proof and failure reception remain EMAIL-WP-0007-T04/CSOC-WP-0010-T04/T05 obligations. Current receiver image and SMTP/sender/caller admission remain unchanged by source preparation. + + +## T06 — Prepare atomic pair custody and its admitted front door + +```task +id: RPF-WP-0053-T06 +status: progress +priority: high +``` + +2026-10-08 delivery preparation exposed a concrete custody gap: Warden's generic +paste-once desk performs a single-field KV PUT, replacing the whole record. Two +runs at one path discard the first field. Prepare a platform-owned pair-aware +custody procedure and reviewed writer identity/front door for the two new records, +initial CAS=0, both AWS fields in one atomic write, no secret argv/output/files, +bounded auth/self-revocation and metadata-only KV-version evidence. Do not use +the read-only delivery role, the structure-only administration lane, a generic +agent read grant, a retired ops-mason builder grant or two single-field desk PUTs. +Design/implementation can progress independently; actual provider key provisioning +needs its exact owner-reviewed attended custody act. This is a live handoff from +WARDEN-WP-0043 and the credential-delivery preparation, not a hidden prose residual. + +## 2026-10-08 — Credential delivery preparation + +Draft CCR-2026-0036 and CCR-2026-0037 now bind separate application KV paths, +five-minute/four-use native readers, exact namespaces/ServiceAccount/audience and +root-owned private delivery files. Both remain draft/unconfirmed, with no reviews +or native admission evidence. Their read policies match the existing generator; +validator errors and delegated-applier scope violations are empty, with expected +unconfirmed-binding warnings. Proposed ServiceAccounts disable automount and have +no new RBAC grants. No existing credential, Secret or cluster object was read or +changed. + +scripts/application_backup_credentials.py previews without effects and performs +only admitted, source-hash/KV-version-pinned node reads; token revoked before atomic +no-overwrite private publication, readback equality, metadata-only receipts. It +refuses source/scope/version drift, dangerous files, injection, unconfirmed revoke +and a different existing file. A separate YAML-to-public-JSON exporter preserves +canonical source updates while keeping the node helper dependency-free. Thirty +synthetic delivery cases, four export cases and thirty primary/retention cases +pass; none establish live authorization. Review: +docs/credential-lane-designs/application-backup-delivery-2026-10-08.md. + +WARDEN-WP-0043 fixes administration/provisioning intent collisions with surveys; +installed Warden was rebuilt, phase-memory retained, current catalog hash +5e6b217aa1f6 verified. Surveys now point to ops-mason-survey per MASON-IN-0004. +Structure requests require attended OIDC; pair provisioning is only a generic +platform pointer until T06 supplies the correct custody contract. T01/T02/T03/T05 +remain open; no credentials delivered, productive capture/upload or timer enabled. + +## 2026-10-08 — Pair writer preview + +`scripts/application_backup_pair_custody.py` previews both draft CCRs. The +intended writer policy is `backup-pair-writer` and `WRITER_ADMITTED` is false, +so `provision()` returns before metadata or CAS. An admitted writer, covered +only by a synthetic transport, must present that one policy, TTL at most 5 +minutes and at most 2 uses, prove metadata absence, send both AWS fields in one +`cas: 0` body, and revoke. Receipts contain no values. The delivery reader and +a one-field desk remain refused. No IAM application, provider key, OpenBao +version or node file was created. Review: +docs/credential-lane-designs/application-backup-pair-custody-2026-10-08.md. +T05 stays wait for owner admission of the writer, prefix and escrow. T06 stays +progress until that attended act.