Admit and install the backup pair writer.
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s

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
This commit is contained in:
codex 2026-10-08 22:41:20 +02:00
parent acbc78ffea
commit 9b658e3f19
8 changed files with 280 additions and 8 deletions

View file

@ -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.

View file

@ -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"]
}

View file

@ -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",

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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",
[

View file

@ -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