From 9b658e3f19d0c67786cc0548e39fcccd50878c32 Mon Sep 17 00:00:00 2001 From: codex Date: Thu, 8 Oct 2026 22:41:20 +0200 Subject: [PATCH] Admit and install the backup pair writer. The OpenBao policy matches the reviewed file. Both publisher KV paths are still empty because the bootstrap key cannot create IAM applications. Assistant: grok Assistant-Session: 01a11cdb-f9fe-7011-947c-936e72007e54 --- ...lication-backup-pair-custody-2026-10-08.md | 19 ++- openbao/policies/backup-pair-writer.hcl | 28 ++++ scripts/application_backup_pair_custody.py | 4 +- scripts/install-backup-pair-writer.sh | 11 ++ scripts/survey-backup-iam.py | 153 ++++++++++++++++++ scripts/survey-backup-pair-metadata.sh | 36 +++++ tests/test_application_backup_pair_custody.py | 27 +++- ...WP-0053-coulomb-paired-backup-admission.md | 10 +- 8 files changed, 280 insertions(+), 8 deletions(-) create mode 100644 openbao/policies/backup-pair-writer.hcl create mode 100755 scripts/install-backup-pair-writer.sh create mode 100644 scripts/survey-backup-iam.py create mode 100755 scripts/survey-backup-pair-metadata.sh 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 index 75d2158..43519c8 100644 --- 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 @@ -32,6 +32,19 @@ call, key generation, file write or timer change. - 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. +The founder admitted this identity on 2026-10-08. The words used were +"bucket-pair-writer"; the OpenBao policy name is `backup-pair-writer`. +No implemented SecurityCaseType covers the pair: `workload-kv-read` is still +only an inventory candidate, and ops-mason can survey structure but cannot +write it. The attended install is `scripts/install-backup-pair-writer.sh`. +The installed policy matches the file +(`86e435900bc6912fada10c526bfa865127a0e28acd328cf5f5f69e8d62bda310`), so +`WRITER_ADMITTED` is true. The command-line preview still does not write. + +Both KV paths are absent. The bootstrap key is denied IAM application and +policy list (HTTP 403), so it cannot create the two publisher applications. +The live bucket policy `resource-control-scoped-backup` allows one principal, +`application_id:ee80333e-5bab-4a04-85a9-5bdb46b7b3c1`, under `platform-pg/*`, +including object delete. No second application was added and no key was +stored. CCR-2026-0036 and CCR-2026-0037 stay draft. The next founder act is +creating the two IAM applications in the console, then one CAS=0 write each. diff --git a/openbao/policies/backup-pair-writer.hcl b/openbao/policies/backup-pair-writer.hcl new file mode 100644 index 0000000..381c849 --- /dev/null +++ b/openbao/policies/backup-pair-writer.hcl @@ -0,0 +1,28 @@ +# Attended writer for the two application-archive publisher pairs. +# Founder admission 2026-10-08. CAS=0 and one-write behavior are enforced by +# scripts/application_backup_pair_custody.py. This policy cannot delete, +# list, or read secret values. + +path "platform/data/workloads/coulomb-social/backup-primary" { + capabilities = ["create", "update"] +} + +path "platform/data/workloads/email-connect/backup-primary" { + capabilities = ["create", "update"] +} + +path "platform/metadata/workloads/coulomb-social/backup-primary" { + capabilities = ["read"] +} + +path "platform/metadata/workloads/email-connect/backup-primary" { + capabilities = ["read"] +} + +path "auth/token/lookup-self" { + capabilities = ["read"] +} + +path "auth/token/revoke-self" { + capabilities = ["update"] +} diff --git a/scripts/application_backup_pair_custody.py b/scripts/application_backup_pair_custody.py index 7657210..412c6c0 100644 --- a/scripts/application_backup_pair_custody.py +++ b/scripts/application_backup_pair_custody.py @@ -18,7 +18,9 @@ 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 +# Founder admission 2026-10-08. The installed OpenBao policy matches +# openbao/policies/backup-pair-writer.hcl. The CLI still does not write. +WRITER_ADMITTED = True PROFILES = { "coulomb-social": "CCR-2026-0036", "email-connect": "CCR-2026-0037", diff --git a/scripts/install-backup-pair-writer.sh b/scripts/install-backup-pair-writer.sh new file mode 100755 index 0000000..25ebacc --- /dev/null +++ b/scripts/install-backup-pair-writer.sh @@ -0,0 +1,11 @@ +#!/bin/sh +# Install backup-pair-writer. Silent: warden access rejects any child output. +set -eu +policy=/home/worsch/railiance-platform/openbao/policies/backup-pair-writer.hcl +receipt_dir=/home/worsch/.local/state/railiance/receipts +umask 077 +mkdir -p "$receipt_dir" +bao policy write backup-pair-writer "$policy" >/dev/null 2>&1 || exit 31 +bao policy read backup-pair-writer >"$receipt_dir/backup-pair-writer.policy" 2>/dev/null || exit 32 +sha256sum "$receipt_dir/backup-pair-writer.policy" >"$receipt_dir/backup-pair-writer.sha256" || exit 33 +exit 0 diff --git a/scripts/survey-backup-iam.py b/scripts/survey-backup-iam.py new file mode 100644 index 0000000..bde0c0f --- /dev/null +++ b/scripts/survey-backup-iam.py @@ -0,0 +1,153 @@ +#!/usr/bin/env python3 +"""Survey Scaleway IAM names and the backup bucket policy. + +Reads the bootstrap and scoped-backup pairs in memory. Writes names, ids and +the bucket policy only. Never prints a credential. +""" + +from __future__ import annotations + +import datetime as dt +import hashlib +import hmac +import json +import subprocess +import urllib.error +import urllib.parse +import urllib.request +from pathlib import Path + +RECEIPT = Path("/home/worsch/.local/state/railiance/receipts/backup-iam-survey.json") +STAGE = Path("/home/worsch/.local/state/railiance/receipts/backup-iam-survey.stage") +BUCKET = "railiance-platform-pg-backup" +HOST = "s3.nl-ams.scw.cloud" + + +def stage(text): + STAGE.parent.mkdir(mode=0o700, exist_ok=True) + STAGE.write_text(text + "\n") + STAGE.chmod(0o600) + + +def kv(path): + proc = subprocess.run( + ["bao", "kv", "get", "-format=json", path], + capture_output=True, + ) + if proc.returncode: + stage(f"kv_read_failed {path} {proc.returncode}") + raise SystemExit(41) + stage(f"kv_read_ok {path}") + return json.loads(proc.stdout)["data"]["data"] + + +def scaleway(secret, url): + request = urllib.request.Request(url, headers={"X-Auth-Token": secret}) + try: + with urllib.request.urlopen(request, timeout=30) as response: + return response.status, json.loads(response.read().decode()) + except urllib.error.HTTPError as exc: + return exc.code, None + + +def sign(access_key, secret_key, canonical_uri, query): + now = dt.datetime.now(dt.timezone.utc) + amz_date = now.strftime("%Y%m%dT%H%M%SZ") + day = now.strftime("%Y%m%d") + payload = hashlib.sha256(b"").hexdigest() + canonical_query = urllib.parse.urlencode(sorted(query.items())) + canonical = "\n".join( + [ + "GET", + canonical_uri, + canonical_query, + f"host:{HOST}", + f"x-amz-content-sha256:{payload}", + f"x-amz-date:{amz_date}", + "", + "host;x-amz-content-sha256;x-amz-date", + payload, + ] + ) + scope = f"{day}/nl-ams/s3/aws4_request" + to_sign = "\n".join( + ["AWS4-HMAC-SHA256", amz_date, scope, hashlib.sha256(canonical.encode()).hexdigest()] + ) + key = ("AWS4" + secret_key).encode() + for part in (day, "nl-ams", "s3", "aws4_request"): + key = hmac.new(key, part.encode(), hashlib.sha256).digest() + signature = hmac.new(key, to_sign.encode(), hashlib.sha256).hexdigest() + return { + "Authorization": ( + "AWS4-HMAC-SHA256 " + f"Credential={access_key}/{scope}, " + "SignedHeaders=host;x-amz-content-sha256;x-amz-date, " + f"Signature={signature}" + ), + "x-amz-date": amz_date, + "x-amz-content-sha256": payload, + } + + +def bucket_policy(access_key, secret_key): + query = {"policy": ""} + headers = sign(access_key, secret_key, f"/{BUCKET}", query) + url = f"https://{HOST}/{BUCKET}?policy" + request = urllib.request.Request(url, headers=headers) + try: + with urllib.request.urlopen(request, timeout=30) as response: + return response.status, json.loads(response.read().decode()) + except urllib.error.HTTPError as exc: + return exc.code, None + + +def main(): + boot = kv("platform/workloads/railiance/scaleway/bootstrap") + backup = kv("platform/workloads/railiance/backup/object-storage") + org = boot["DEFAULT_ORGANIZATION_ID"] + project = boot["DEFAULT_PROJECT_ID"] + app_status, applications = scaleway( + boot["SECRET_KEY"], + "https://api.scaleway.com/iam/v1alpha1/applications" + f"?organization_id={urllib.parse.quote(org)}&page_size=100", + ) + policy_status, policies = scaleway( + boot["SECRET_KEY"], + "https://api.scaleway.com/iam/v1alpha1/policies" + f"?organization_id={urllib.parse.quote(org)}&page_size=100", + ) + s3_status, document = bucket_policy(backup["ACCESS_KEY"], backup["SECRET_KEY"]) + receipt = { + "organization_id": org, + "project_id": project, + "application_list_status": app_status, + "applications": [ + {"id": item.get("id"), "name": item.get("name")} + for item in (applications or {}).get("applications", []) + ], + "policy_list_status": policy_status, + "policies": [ + { + "id": item.get("id"), + "name": item.get("name"), + "application_id": (item.get("application_id") or item.get("principal", {}).get("id")), + } + for item in (policies or {}).get("policies", []) + ], + "bucket_policy_status": s3_status, + "bucket_policy": document, + } + RECEIPT.parent.mkdir(mode=0o700, exist_ok=True) + RECEIPT.write_text(json.dumps(receipt, indent=2) + "\n") + RECEIPT.chmod(0o600) + + +if __name__ == "__main__": + try: + main() + stage("ok") + except SystemExit: + raise + except Exception as exc: + stage("failed " + type(exc).__name__) + raise SystemExit(42) from None diff --git a/scripts/survey-backup-pair-metadata.sh b/scripts/survey-backup-pair-metadata.sh new file mode 100755 index 0000000..82f7c86 --- /dev/null +++ b/scripts/survey-backup-pair-metadata.sh @@ -0,0 +1,36 @@ +#!/bin/sh +# Record KV metadata versions only. Silent for warden access. +set -eu +receipt_dir=/home/worsch/.local/state/railiance/receipts +umask 077 +mkdir -p "$receipt_dir" +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT +python3 - "$tmp" "$receipt_dir/backup-pair-metadata.json" << 'PY' +import json, subprocess, sys +tmp, dest = sys.argv[1:] +paths = [ + "platform/workloads/coulomb-social/backup-primary", + "platform/workloads/email-connect/backup-primary", + "platform/workloads/railiance/scaleway/bootstrap", + "platform/workloads/railiance/backup/object-storage", +] +rows = [] +for path in paths: + out = subprocess.run( + ["bao", "kv", "metadata", "get", "-format=json", path], + capture_output=True, + ) + row = {"path": path, "returncode": out.returncode} + if out.returncode == 0: + data = json.loads(out.stdout)["data"] + row["current_version"] = data.get("current_version") + versions = data.get("versions") or {} + row["version_count"] = len(versions) + else: + err = (out.stderr + out.stdout).decode("utf-8", "replace") + row["absent"] = "No value found" in err + rows.append(row) +open(dest, "w").write(json.dumps({"paths": rows}, indent=2) + "\n") +PY +exit 0 diff --git a/tests/test_application_backup_pair_custody.py b/tests/test_application_backup_pair_custody.py index f4ee2ad..b942cdc 100644 --- a/tests/test_application_backup_pair_custody.py +++ b/tests/test_application_backup_pair_custody.py @@ -53,7 +53,7 @@ def test_preview_reads_draft_contract_and_writes_nothing(application, capsys, mo receipt = json.loads(capsys.readouterr().out) assert receipt["status"] == "preview" assert receipt["ccr_status"] == "draft" - assert receipt["writer_admitted"] is False + assert receipt["writer_admitted"] is True assert receipt["openbao_write"] is False assert receipt["provider_keys"] == "not_created" assert receipt["effects"] == [] @@ -63,7 +63,8 @@ def test_preview_reads_draft_contract_and_writes_nothing(application, capsys, mo assert "AKIA" not in rendered -def test_unreviewed_writer_makes_no_transport_call(): +def test_unreviewed_writer_makes_no_transport_call(monkeypatch): + monkeypatch.setattr(custody, "WRITER_ADMITTED", False) transport = Transport() with pytest.raises(custody.CustodyError, match="writer_identity_unreviewed"): custody.provision("email-connect", PAIR, identity(), transport) @@ -101,6 +102,28 @@ def test_reader_policy_and_existing_version_are_refused(monkeypatch): assert [call[0] for call in transport.calls] == ["metadata"] +def test_writer_policy_is_pair_scoped_and_cannot_read_or_delete(): + text = (ROOT / "openbao/policies/backup-pair-writer.hcl").read_text(encoding="utf-8") + for application in custody.PROFILES: + assert f'path "platform/data/workloads/{application}/backup-primary"' in text + assert f'path "platform/metadata/workloads/{application}/backup-primary"' in text + capabilities = [ + line.split("=", 1)[1] + for line in text.splitlines() + if line.strip().startswith("capabilities") + ] + assert capabilities + assert all("delete" not in line and "list" not in line for line in capabilities) + assert 'capabilities = ["read"]' in text + data_blocks = [ + block + for block in text.split("path ") + if "platform/data/" in block + ] + assert data_blocks + assert all('["create", "update"]' in block and "read" not in block for block in data_blocks) + + @pytest.mark.parametrize( "values", [ diff --git a/workplans/RPF-WP-0053-coulomb-paired-backup-admission.md b/workplans/RPF-WP-0053-coulomb-paired-backup-admission.md index 9e8336f..b389ae2 100644 --- a/workplans/RPF-WP-0053-coulomb-paired-backup-admission.md +++ b/workplans/RPF-WP-0053-coulomb-paired-backup-admission.md @@ -227,8 +227,14 @@ remain open; no credentials delivered, productive capture/upload or timer enable ## 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 +intended writer policy is `backup-pair-writer` and The founder admitted this writer on 2026-10-08, using the words +"bucket-pair-writer". The policy name remains `backup-pair-writer` +(`openbao/policies/backup-pair-writer.hcl`). The installed policy matches +that file, and `WRITER_ADMITTED` is true. Both backup-primary KV paths are +absent. The bootstrap key cannot list IAM applications (403). The live bucket +policy still names only application ee80333e-5bab-4a04-85a9-5bdb46b7b3c1 on +`platform-pg/*`. No publisher key was created. T05 stays wait for the two +console-created applications and their CAS=0 pairs. 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